From 97df38f1daa90c13e3b280a2f8fa9c7a5745ad3f Mon Sep 17 00:00:00 2001 From: On Freund Date: Mon, 16 Feb 2026 10:47:24 -0500 Subject: [PATCH] Add MTA New York City Transit integration (#156846) Co-authored-by: Claude Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + homeassistant/components/mta/__init__.py | 28 ++ homeassistant/components/mta/config_flow.py | 151 ++++++ homeassistant/components/mta/const.py | 11 + homeassistant/components/mta/coordinator.py | 110 +++++ homeassistant/components/mta/manifest.json | 12 + .../components/mta/quality_scale.yaml | 88 ++++ homeassistant/components/mta/sensor.py | 147 ++++++ homeassistant/components/mta/strings.json | 65 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/mta/__init__.py | 1 + tests/components/mta/conftest.py | 92 ++++ .../components/mta/snapshots/test_sensor.ambr | 445 ++++++++++++++++++ tests/components/mta/test_config_flow.py | 161 +++++++ tests/components/mta/test_init.py | 29 ++ tests/components/mta/test_sensor.py | 30 ++ 19 files changed, 1385 insertions(+) create mode 100644 homeassistant/components/mta/__init__.py create mode 100644 homeassistant/components/mta/config_flow.py create mode 100644 homeassistant/components/mta/const.py create mode 100644 homeassistant/components/mta/coordinator.py create mode 100644 homeassistant/components/mta/manifest.json create mode 100644 homeassistant/components/mta/quality_scale.yaml create mode 100644 homeassistant/components/mta/sensor.py create mode 100644 homeassistant/components/mta/strings.json create mode 100644 tests/components/mta/__init__.py create mode 100644 tests/components/mta/conftest.py create mode 100644 tests/components/mta/snapshots/test_sensor.ambr create mode 100644 tests/components/mta/test_config_flow.py create mode 100644 tests/components/mta/test_init.py create mode 100644 tests/components/mta/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index f81e1b94719..c53d65b5957 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1068,6 +1068,8 @@ build.json @home-assistant/supervisor /homeassistant/components/mqtt/ @emontnemery @jbouwh @bdraco /tests/components/mqtt/ @emontnemery @jbouwh @bdraco /homeassistant/components/msteams/ @peroyvind +/homeassistant/components/mta/ @OnFreund +/tests/components/mta/ @OnFreund /homeassistant/components/mullvad/ @meichthys /tests/components/mullvad/ @meichthys /homeassistant/components/music_assistant/ @music-assistant @arturpragacz diff --git a/homeassistant/components/mta/__init__.py b/homeassistant/components/mta/__init__.py new file mode 100644 index 00000000000..bfa04ab9b88 --- /dev/null +++ b/homeassistant/components/mta/__init__.py @@ -0,0 +1,28 @@ +"""The MTA New York City Transit integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN as DOMAIN +from .coordinator import MTAConfigEntry, MTADataUpdateCoordinator + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: MTAConfigEntry) -> bool: + """Set up MTA from a config entry.""" + coordinator = MTADataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: MTAConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/mta/config_flow.py b/homeassistant/components/mta/config_flow.py new file mode 100644 index 00000000000..b1f8d51cf43 --- /dev/null +++ b/homeassistant/components/mta/config_flow.py @@ -0,0 +1,151 @@ +"""Config flow for MTA New York City Transit integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from pymta import LINE_TO_FEED, MTAFeedError, SubwayFeed +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import CONF_LINE, CONF_STOP_ID, CONF_STOP_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class MTAConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for MTA.""" + + VERSION = 1 + MINOR_VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self.data: dict[str, Any] = {} + self.stops: dict[str, str] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + self.data[CONF_LINE] = user_input[CONF_LINE] + return await self.async_step_stop() + + lines = sorted(LINE_TO_FEED.keys()) + line_options = [SelectOptionDict(value=line, label=line) for line in lines] + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_LINE): SelectSelector( + SelectSelectorConfig( + options=line_options, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ), + errors=errors, + ) + + async def async_step_stop( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the stop step.""" + errors: dict[str, str] = {} + + if user_input is not None: + stop_id = user_input[CONF_STOP_ID] + self.data[CONF_STOP_ID] = stop_id + stop_name = self.stops.get(stop_id, stop_id) + self.data[CONF_STOP_NAME] = stop_name + + unique_id = f"{self.data[CONF_LINE]}_{stop_id}" + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + # Test connection to real-time GTFS-RT feed (different from static GTFS used by get_stops) + try: + await self._async_test_connection() + except MTAFeedError: + errors["base"] = "cannot_connect" + else: + title = f"{self.data[CONF_LINE]} Line - {stop_name}" + return self.async_create_entry( + title=title, + data=self.data, + ) + + try: + self.stops = await self._async_get_stops(self.data[CONF_LINE]) + except MTAFeedError: + _LOGGER.exception("Error fetching stops for line %s", self.data[CONF_LINE]) + return self.async_abort(reason="cannot_connect") + + if not self.stops: + _LOGGER.error("No stops found for line %s", self.data[CONF_LINE]) + return self.async_abort(reason="no_stops") + + stop_options = [ + SelectOptionDict(value=stop_id, label=stop_name) + for stop_id, stop_name in sorted(self.stops.items(), key=lambda x: x[1]) + ] + + return self.async_show_form( + step_id="stop", + data_schema=vol.Schema( + { + vol.Required(CONF_STOP_ID): SelectSelector( + SelectSelectorConfig( + options=stop_options, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ), + errors=errors, + description_placeholders={"line": self.data[CONF_LINE]}, + ) + + async def _async_get_stops(self, line: str) -> dict[str, str]: + """Get stops for a line from the library.""" + feed_id = SubwayFeed.get_feed_id_for_route(line) + session = aiohttp_client.async_get_clientsession(self.hass) + + subway_feed = SubwayFeed(feed_id=feed_id, session=session) + stops_list = await subway_feed.get_stops(route_id=line) + + stops = {} + for stop in stops_list: + stop_id = stop["stop_id"] + stop_name = stop["stop_name"] + # Add direction label (stop_id always ends in N or S) + direction = stop_id[-1] + stops[stop_id] = f"{stop_name} ({direction} direction)" + + return stops + + async def _async_test_connection(self) -> None: + """Test connection to MTA feed.""" + feed_id = SubwayFeed.get_feed_id_for_route(self.data[CONF_LINE]) + session = aiohttp_client.async_get_clientsession(self.hass) + + subway_feed = SubwayFeed(feed_id=feed_id, session=session) + await subway_feed.get_arrivals( + route_id=self.data[CONF_LINE], + stop_id=self.data[CONF_STOP_ID], + max_arrivals=1, + ) diff --git a/homeassistant/components/mta/const.py b/homeassistant/components/mta/const.py new file mode 100644 index 00000000000..4088401e8bc --- /dev/null +++ b/homeassistant/components/mta/const.py @@ -0,0 +1,11 @@ +"""Constants for the MTA New York City Transit integration.""" + +from datetime import timedelta + +DOMAIN = "mta" + +CONF_LINE = "line" +CONF_STOP_ID = "stop_id" +CONF_STOP_NAME = "stop_name" + +UPDATE_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/mta/coordinator.py b/homeassistant/components/mta/coordinator.py new file mode 100644 index 00000000000..fd1edee882e --- /dev/null +++ b/homeassistant/components/mta/coordinator.py @@ -0,0 +1,110 @@ +"""Data update coordinator for MTA New York City Transit.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +import logging + +from pymta import MTAFeedError, SubwayFeed + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +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 + +from .const import CONF_LINE, CONF_STOP_ID, DOMAIN, UPDATE_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class MTAArrival: + """Represents a single train arrival.""" + + arrival_time: datetime + minutes_until: int + route_id: str + destination: str + + +@dataclass +class MTAData: + """Data for MTA arrivals.""" + + arrivals: list[MTAArrival] + + +type MTAConfigEntry = ConfigEntry[MTADataUpdateCoordinator] + + +class MTADataUpdateCoordinator(DataUpdateCoordinator[MTAData]): + """Class to manage fetching MTA data.""" + + config_entry: MTAConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: MTAConfigEntry) -> None: + """Initialize.""" + self.line = config_entry.data[CONF_LINE] + self.stop_id = config_entry.data[CONF_STOP_ID] + + self.feed_id = SubwayFeed.get_feed_id_for_route(self.line) + session = async_get_clientsession(hass) + self.subway_feed = SubwayFeed(feed_id=self.feed_id, session=session) + + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + ) + + async def _async_update_data(self) -> MTAData: + """Fetch data from MTA.""" + _LOGGER.debug( + "Fetching data for line=%s, stop=%s, feed=%s", + self.line, + self.stop_id, + self.feed_id, + ) + + try: + library_arrivals = await self.subway_feed.get_arrivals( + route_id=self.line, + stop_id=self.stop_id, + max_arrivals=3, + ) + except MTAFeedError as err: + raise UpdateFailed(f"Error fetching MTA data: {err}") from err + + now = dt_util.now() + arrivals: list[MTAArrival] = [] + + for library_arrival in library_arrivals: + # Convert UTC arrival time to local time + arrival_time = dt_util.as_local(library_arrival.arrival_time) + + minutes_until = int((arrival_time - now).total_seconds() / 60) + + _LOGGER.debug( + "Stop %s: arrival_time=%s, minutes_until=%d, route=%s", + library_arrival.stop_id, + arrival_time, + minutes_until, + library_arrival.route_id, + ) + + arrivals.append( + MTAArrival( + arrival_time=arrival_time, + minutes_until=minutes_until, + route_id=library_arrival.route_id, + destination=library_arrival.destination, + ) + ) + + _LOGGER.debug("Returning %d arrivals", len(arrivals)) + + return MTAData(arrivals=arrivals) diff --git a/homeassistant/components/mta/manifest.json b/homeassistant/components/mta/manifest.json new file mode 100644 index 00000000000..b1d82533df6 --- /dev/null +++ b/homeassistant/components/mta/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "mta", + "name": "MTA New York City Transit", + "codeowners": ["@OnFreund"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/mta", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["pymta"], + "quality_scale": "silver", + "requirements": ["py-nymta==0.3.4"] +} diff --git a/homeassistant/components/mta/quality_scale.yaml b/homeassistant/components/mta/quality_scale.yaml new file mode 100644 index 00000000000..2cd98e9f45a --- /dev/null +++ b/homeassistant/components/mta/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not register custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Integration does not explicitly subscribe to events in async_added_to_hass. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No configuration options. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: No authentication required. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: No discovery. + discovery: + status: exempt + comment: No discovery. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: + status: exempt + comment: No physical devices. + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: Integration tracks a single configured stop, not dynamically discovered devices. + entity-category: + status: exempt + comment: All entities are primary entities without specific categories. + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: N/A + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No repairs needed currently. + stale-devices: + status: exempt + comment: Integration tracks a single configured stop per entry, devices cannot become stale. + + # Platinum + async-dependency: todo + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/mta/sensor.py b/homeassistant/components/mta/sensor.py new file mode 100644 index 00000000000..5f352caa7d2 --- /dev/null +++ b/homeassistant/components/mta/sensor.py @@ -0,0 +1,147 @@ +"""Sensor platform for MTA New York City Transit.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import CONF_LINE, CONF_STOP_ID, CONF_STOP_NAME, DOMAIN +from .coordinator import MTAArrival, MTAConfigEntry, MTADataUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class MTASensorEntityDescription(SensorEntityDescription): + """Describes an MTA sensor entity.""" + + arrival_index: int + value_fn: Callable[[MTAArrival], datetime | str] + + +SENSOR_DESCRIPTIONS: tuple[MTASensorEntityDescription, ...] = ( + MTASensorEntityDescription( + key="next_arrival", + translation_key="next_arrival", + device_class=SensorDeviceClass.TIMESTAMP, + arrival_index=0, + value_fn=lambda arrival: arrival.arrival_time, + ), + MTASensorEntityDescription( + key="next_arrival_route", + translation_key="next_arrival_route", + arrival_index=0, + value_fn=lambda arrival: arrival.route_id, + ), + MTASensorEntityDescription( + key="next_arrival_destination", + translation_key="next_arrival_destination", + arrival_index=0, + value_fn=lambda arrival: arrival.destination, + ), + MTASensorEntityDescription( + key="second_arrival", + translation_key="second_arrival", + device_class=SensorDeviceClass.TIMESTAMP, + arrival_index=1, + value_fn=lambda arrival: arrival.arrival_time, + ), + MTASensorEntityDescription( + key="second_arrival_route", + translation_key="second_arrival_route", + arrival_index=1, + value_fn=lambda arrival: arrival.route_id, + ), + MTASensorEntityDescription( + key="second_arrival_destination", + translation_key="second_arrival_destination", + arrival_index=1, + value_fn=lambda arrival: arrival.destination, + ), + MTASensorEntityDescription( + key="third_arrival", + translation_key="third_arrival", + device_class=SensorDeviceClass.TIMESTAMP, + arrival_index=2, + value_fn=lambda arrival: arrival.arrival_time, + ), + MTASensorEntityDescription( + key="third_arrival_route", + translation_key="third_arrival_route", + arrival_index=2, + value_fn=lambda arrival: arrival.route_id, + ), + MTASensorEntityDescription( + key="third_arrival_destination", + translation_key="third_arrival_destination", + arrival_index=2, + value_fn=lambda arrival: arrival.destination, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MTAConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up MTA sensor based on a config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + MTASensor(coordinator, entry, description) + for description in SENSOR_DESCRIPTIONS + ) + + +class MTASensor(CoordinatorEntity[MTADataUpdateCoordinator], SensorEntity): + """Sensor for MTA train arrivals.""" + + _attr_has_entity_name = True + entity_description: MTASensorEntityDescription + + def __init__( + self, + coordinator: MTADataUpdateCoordinator, + entry: MTAConfigEntry, + description: MTASensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + + self.entity_description = description + line = entry.data[CONF_LINE] + stop_id = entry.data[CONF_STOP_ID] + stop_name = entry.data.get(CONF_STOP_NAME, stop_id) + + self._attr_unique_id = f"{entry.unique_id}-{description.key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name=f"{line} Line - {stop_name} ({stop_id})", + manufacturer="MTA", + model="Subway", + entry_type=DeviceEntryType.SERVICE, + ) + + @property + def native_value(self) -> datetime | str | None: + """Return the state of the sensor.""" + arrivals = self.coordinator.data.arrivals + if len(arrivals) <= self.entity_description.arrival_index: + return None + + return self.entity_description.value_fn( + arrivals[self.entity_description.arrival_index] + ) diff --git a/homeassistant/components/mta/strings.json b/homeassistant/components/mta/strings.json new file mode 100644 index 00000000000..4f3b3be7d93 --- /dev/null +++ b/homeassistant/components/mta/strings.json @@ -0,0 +1,65 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "no_stops": "No stops found for this line. The line may not be currently running." + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "step": { + "stop": { + "data": { + "stop_id": "Stop and direction" + }, + "data_description": { + "stop_id": "Select the stop and direction you want to track" + }, + "description": "Choose a stop on the {line} line. The direction is included with each stop.", + "title": "Select stop and direction" + }, + "user": { + "data": { + "line": "Line" + }, + "data_description": { + "line": "The subway line to track" + }, + "description": "Choose the subway line you want to track.", + "title": "Select subway line" + } + } + }, + "entity": { + "sensor": { + "next_arrival": { + "name": "Next arrival" + }, + "next_arrival_destination": { + "name": "Next arrival destination" + }, + "next_arrival_route": { + "name": "Next arrival route" + }, + "second_arrival": { + "name": "Second arrival" + }, + "second_arrival_destination": { + "name": "Second arrival destination" + }, + "second_arrival_route": { + "name": "Second arrival route" + }, + "third_arrival": { + "name": "Third arrival" + }, + "third_arrival_destination": { + "name": "Third arrival destination" + }, + "third_arrival_route": { + "name": "Third arrival route" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 463fd28ec96..04902a57f02 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -444,6 +444,7 @@ FLOWS = { "motionmount", "mpd", "mqtt", + "mta", "mullvad", "music_assistant", "mutesync", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a18fbe6822c..e111bae54b2 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4348,6 +4348,12 @@ } } }, + "mta": { + "name": "MTA New York City Transit", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "mullvad": { "name": "Mullvad VPN", "integration_type": "service", diff --git a/requirements_all.txt b/requirements_all.txt index 068635d57f4..bc9fde4c047 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1851,6 +1851,9 @@ py-nextbusnext==2.3.0 # homeassistant.components.nightscout py-nightscout==1.2.2 +# homeassistant.components.mta +py-nymta==0.3.4 + # homeassistant.components.schluter py-schluter==0.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f79d9c975ee..1dff39369c2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1600,6 +1600,9 @@ py-nextbusnext==2.3.0 # homeassistant.components.nightscout py-nightscout==1.2.2 +# homeassistant.components.mta +py-nymta==0.3.4 + # homeassistant.components.ecovacs py-sucks==0.9.11 diff --git a/tests/components/mta/__init__.py b/tests/components/mta/__init__.py new file mode 100644 index 00000000000..70fa60764d0 --- /dev/null +++ b/tests/components/mta/__init__.py @@ -0,0 +1 @@ +"""Tests for the MTA New York City Transit integration.""" diff --git a/tests/components/mta/conftest.py b/tests/components/mta/conftest.py new file mode 100644 index 00000000000..fdbd91b4611 --- /dev/null +++ b/tests/components/mta/conftest.py @@ -0,0 +1,92 @@ +"""Test helpers for MTA tests.""" + +from collections.abc import Generator +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock, patch + +from pymta import Arrival +import pytest + +from homeassistant.components.mta.const import CONF_LINE, CONF_STOP_ID, CONF_STOP_NAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain="mta", + data={ + CONF_LINE: "1", + CONF_STOP_ID: "127N", + CONF_STOP_NAME: "Times Sq - 42 St (N direction)", + }, + unique_id="1_127N", + entry_id="01J0000000000000000000000", + title="1 Line - Times Sq - 42 St (N direction)", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.mta.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_subway_feed() -> Generator[MagicMock]: + """Create a mock SubwayFeed for both coordinator and config flow.""" + # Fixed arrival times: 5, 10, and 15 minutes after test frozen time (2023-10-21 00:00:00 UTC) + mock_arrivals = [ + Arrival( + arrival_time=datetime(2023, 10, 21, 0, 5, 0, tzinfo=UTC), + route_id="1", + stop_id="127N", + destination="Van Cortlandt Park - 242 St", + ), + Arrival( + arrival_time=datetime(2023, 10, 21, 0, 10, 0, tzinfo=UTC), + route_id="1", + stop_id="127N", + destination="Van Cortlandt Park - 242 St", + ), + Arrival( + arrival_time=datetime(2023, 10, 21, 0, 15, 0, tzinfo=UTC), + route_id="1", + stop_id="127N", + destination="Van Cortlandt Park - 242 St", + ), + ] + + mock_stops = [ + { + "stop_id": "127N", + "stop_name": "Times Sq - 42 St", + "stop_sequence": 1, + }, + { + "stop_id": "127S", + "stop_name": "Times Sq - 42 St", + "stop_sequence": 2, + }, + ] + + with ( + patch( + "homeassistant.components.mta.coordinator.SubwayFeed", autospec=True + ) as mock_feed, + patch( + "homeassistant.components.mta.config_flow.SubwayFeed", + new=mock_feed, + ), + ): + mock_instance = mock_feed.return_value + mock_feed.get_feed_id_for_route.return_value = "1" + mock_instance.get_arrivals.return_value = mock_arrivals + mock_instance.get_stops.return_value = mock_stops + + yield mock_feed diff --git a/tests/components/mta/snapshots/test_sensor.ambr b/tests/components/mta/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..8d75b80ca2d --- /dev/null +++ b/tests/components/mta/snapshots/test_sensor.ambr @@ -0,0 +1,445 @@ +# serializer version: 1 +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Next arrival', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Next arrival', + 'platform': 'mta', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'next_arrival', + 'unique_id': '1_127N-next_arrival', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': '1 Line - Times Sq - 42 St (N direction) (127N) Next arrival', + }), + 'context': , + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-10-21T00:05:00+00:00', + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival_destination-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival_destination', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Next arrival destination', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Next arrival destination', + 'platform': 'mta', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'next_arrival_destination', + 'unique_id': '1_127N-next_arrival_destination', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival_destination-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '1 Line - Times Sq - 42 St (N direction) (127N) Next arrival destination', + }), + 'context': , + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival_destination', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Van Cortlandt Park - 242 St', + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival_route-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival_route', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Next arrival route', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Next arrival route', + 'platform': 'mta', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'next_arrival_route', + 'unique_id': '1_127N-next_arrival_route', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival_route-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '1 Line - Times Sq - 42 St (N direction) (127N) Next arrival route', + }), + 'context': , + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival_route', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Second arrival', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Second arrival', + 'platform': 'mta', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'second_arrival', + 'unique_id': '1_127N-second_arrival', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': '1 Line - Times Sq - 42 St (N direction) (127N) Second arrival', + }), + 'context': , + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-10-21T00:10:00+00:00', + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival_destination-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival_destination', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Second arrival destination', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Second arrival destination', + 'platform': 'mta', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'second_arrival_destination', + 'unique_id': '1_127N-second_arrival_destination', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival_destination-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '1 Line - Times Sq - 42 St (N direction) (127N) Second arrival destination', + }), + 'context': , + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival_destination', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Van Cortlandt Park - 242 St', + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival_route-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival_route', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Second arrival route', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Second arrival route', + 'platform': 'mta', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'second_arrival_route', + 'unique_id': '1_127N-second_arrival_route', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival_route-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '1 Line - Times Sq - 42 St (N direction) (127N) Second arrival route', + }), + 'context': , + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival_route', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Third arrival', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Third arrival', + 'platform': 'mta', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'third_arrival', + 'unique_id': '1_127N-third_arrival', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': '1 Line - Times Sq - 42 St (N direction) (127N) Third arrival', + }), + 'context': , + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-10-21T00:15:00+00:00', + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival_destination-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival_destination', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Third arrival destination', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Third arrival destination', + 'platform': 'mta', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'third_arrival_destination', + 'unique_id': '1_127N-third_arrival_destination', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival_destination-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '1 Line - Times Sq - 42 St (N direction) (127N) Third arrival destination', + }), + 'context': , + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival_destination', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Van Cortlandt Park - 242 St', + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival_route-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival_route', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Third arrival route', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Third arrival route', + 'platform': 'mta', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'third_arrival_route', + 'unique_id': '1_127N-third_arrival_route', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival_route-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '1 Line - Times Sq - 42 St (N direction) (127N) Third arrival route', + }), + 'context': , + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival_route', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- diff --git a/tests/components/mta/test_config_flow.py b/tests/components/mta/test_config_flow.py new file mode 100644 index 00000000000..048ef444cd3 --- /dev/null +++ b/tests/components/mta/test_config_flow.py @@ -0,0 +1,161 @@ +"""Test the MTA config flow.""" + +from unittest.mock import AsyncMock, MagicMock + +from pymta import MTAFeedError + +from homeassistant.components.mta.const import ( + CONF_LINE, + CONF_STOP_ID, + CONF_STOP_NAME, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_form( + hass: HomeAssistant, + mock_subway_feed: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the complete config flow.""" + # Start the flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + # Select line + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_LINE: "1"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "stop" + assert result["errors"] == {} + + # Select stop and complete + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_STOP_ID: "127N"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "1 Line - Times Sq - 42 St (N direction)" + assert result["data"] == { + CONF_LINE: "1", + CONF_STOP_ID: "127N", + CONF_STOP_NAME: "Times Sq - 42 St (N direction)", + } + assert result["result"].unique_id == "1_127N" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_already_configured( + hass: HomeAssistant, + mock_subway_feed: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we handle already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_LINE: "1"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_STOP_ID: "127N"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_form_connection_error( + hass: HomeAssistant, + mock_subway_feed: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we handle connection errors and can recover.""" + mock_instance = mock_subway_feed.return_value + mock_instance.get_arrivals.side_effect = MTAFeedError("Connection error") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_LINE: "1"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_STOP_ID: "127S"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + # Test recovery - reset mock to succeed + mock_instance.get_arrivals.side_effect = None + mock_instance.get_arrivals.return_value = [] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_STOP_ID: "127S"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_get_stops( + hass: HomeAssistant, mock_subway_feed: MagicMock +) -> None: + """Test we abort when we cannot get stops.""" + mock_instance = mock_subway_feed.return_value + mock_instance.get_stops.side_effect = MTAFeedError("Feed error") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_LINE: "1"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_form_no_stops_found( + hass: HomeAssistant, mock_subway_feed: MagicMock +) -> None: + """Test we abort when no stops are found.""" + mock_instance = mock_subway_feed.return_value + mock_instance.get_stops.return_value = [] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_LINE: "1"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_stops" diff --git a/tests/components/mta/test_init.py b/tests/components/mta/test_init.py new file mode 100644 index 00000000000..05751187ce7 --- /dev/null +++ b/tests/components/mta/test_init.py @@ -0,0 +1,29 @@ +"""Test the MTA New York City Transit init.""" + +from unittest.mock import MagicMock + +from homeassistant.components.mta.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_and_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_subway_feed: MagicMock, +) -> None: + """Test setting up and unloading an entry.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert DOMAIN in hass.config_entries.async_domains() + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/mta/test_sensor.py b/tests/components/mta/test_sensor.py new file mode 100644 index 00000000000..29d59dd67d7 --- /dev/null +++ b/tests/components/mta/test_sensor.py @@ -0,0 +1,30 @@ +"""Test the MTA sensor platform.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.freeze_time("2023-10-21") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_subway_feed: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the sensor entity.""" + await hass.config.async_set_time_zone("UTC") + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)