Make NextBus coordinator more resilient and efficient (#126161)

* Make NextBus coordinator more resilient and efficient

Resolves issues where one request failing will prevent all agency
predictions to fail. This also removes redundant requests for
predictions that share the same stop.

* Add unload entry test

* Prevent shutdown if the coordinator is still needed
This commit is contained in:
Ian 2024-09-20 01:18:13 -07:00 committed by GitHub
parent df0195bfe8
commit dccdb71b2d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 212 additions and 60 deletions

View file

@ -13,15 +13,19 @@ PLATFORMS = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up platforms for NextBus.""" """Set up platforms for NextBus."""
entry_agency = entry.data[CONF_AGENCY] entry_agency = entry.data[CONF_AGENCY]
entry_stop = entry.data[CONF_STOP]
coordinator_key = f"{entry_agency}-{entry_stop}"
coordinator: NextBusDataUpdateCoordinator = hass.data.setdefault(DOMAIN, {}).get( coordinator: NextBusDataUpdateCoordinator | None = hass.data.setdefault(
entry_agency DOMAIN, {}
).get(
coordinator_key,
) )
if coordinator is None: if coordinator is None:
coordinator = NextBusDataUpdateCoordinator(hass, entry_agency) coordinator = NextBusDataUpdateCoordinator(hass, entry_agency)
hass.data[DOMAIN][entry_agency] = coordinator hass.data[DOMAIN][coordinator_key] = coordinator
coordinator.add_stop_route(entry.data[CONF_STOP], entry.data[CONF_ROUTE]) coordinator.add_stop_route(entry_stop, entry.data[CONF_ROUTE])
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
@ -33,11 +37,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
if await hass.config_entries.async_unload_platforms(entry, PLATFORMS): if await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
entry_agency = entry.data.get(CONF_AGENCY) entry_agency = entry.data[CONF_AGENCY]
coordinator: NextBusDataUpdateCoordinator = hass.data[DOMAIN][entry_agency] entry_stop = entry.data[CONF_STOP]
coordinator.remove_stop_route(entry.data[CONF_STOP], entry.data[CONF_ROUTE]) coordinator_key = f"{entry_agency}-{entry_stop}"
coordinator: NextBusDataUpdateCoordinator = hass.data[DOMAIN][coordinator_key]
coordinator.remove_stop_route(entry_stop, entry.data[CONF_ROUTE])
if not coordinator.has_routes(): if not coordinator.has_routes():
hass.data[DOMAIN].pop(entry_agency) await coordinator.async_shutdown()
hass.data[DOMAIN].pop(coordinator_key)
return True return True

View file

@ -48,27 +48,63 @@ class NextBusDataUpdateCoordinator(DataUpdateCoordinator):
"""Check if this coordinator is tracking any routes.""" """Check if this coordinator is tracking any routes."""
return len(self._route_stops) > 0 return len(self._route_stops) > 0
async def async_shutdown(self) -> None:
"""If there are no more routes, cancel any scheduled call, and ignore new runs."""
if self.has_routes():
return
await super().async_shutdown()
async def _async_update_data(self) -> dict[str, Any]: async def _async_update_data(self) -> dict[str, Any]:
"""Fetch data from NextBus.""" """Fetch data from NextBus."""
_route_stops = set(self._route_stops) _stops_to_route_stops: dict[str, set[RouteStop]] = {}
self.logger.debug("Updating data from API. Routes: %s", str(_route_stops)) for route_stop in self._route_stops:
_stops_to_route_stops.setdefault(route_stop.stop_id, set()).add(route_stop)
self.logger.debug(
"Updating data from API. Routes: %s", str(_stops_to_route_stops)
)
def _update_data() -> dict: def _update_data() -> dict:
"""Fetch data from NextBus.""" """Fetch data from NextBus."""
self.logger.debug("Updating data from API (executor)") self.logger.debug("Updating data from API (executor)")
predictions: dict[RouteStop, dict[str, Any]] = {} predictions: dict[RouteStop, dict[str, Any]] = {}
for route_stop in _route_stops:
prediction_results: list[dict[str, Any]] = [] for stop_id, route_stops in _stops_to_route_stops.items():
self.logger.debug("Updating data from API (executor) %s", stop_id)
try: try:
prediction_results = self.client.predictions_for_stop( prediction_results = self.client.predictions_for_stop(stop_id)
route_stop.stop_id, route_stop.route_id except NextBusHTTPError as ex:
self.logger.error(
"Error updating %s (executor): %s %s",
str(stop_id),
ex,
getattr(ex, "response", None),
) )
except (NextBusHTTPError, NextBusFormatError) as ex: raise UpdateFailed("Failed updating nextbus data", ex) from ex
except NextBusFormatError as ex:
raise UpdateFailed("Failed updating nextbus data", ex) from ex raise UpdateFailed("Failed updating nextbus data", ex) from ex
if prediction_results: self.logger.debug(
predictions[route_stop] = prediction_results[0] "Prediction results for %s (executor): %s",
str(stop_id),
str(prediction_results),
)
for route_stop in route_stops:
for prediction_result in prediction_results:
if (
prediction_result["stop"]["id"] == route_stop.stop_id
and prediction_result["route"]["id"] == route_stop.route_id
):
predictions[route_stop] = prediction_result
break
else:
self.logger.warning(
"Prediction not found for %s (executor)", str(route_stop)
)
self._predictions = predictions self._predictions = predictions
return predictions return predictions

View file

@ -28,8 +28,10 @@ async def async_setup_entry(
"""Load values from configuration and initialize the platform.""" """Load values from configuration and initialize the platform."""
_LOGGER.debug(config.data) _LOGGER.debug(config.data)
entry_agency = config.data[CONF_AGENCY] entry_agency = config.data[CONF_AGENCY]
entry_stop = config.data[CONF_STOP]
coordinator_key = f"{entry_agency}-{entry_stop}"
coordinator: NextBusDataUpdateCoordinator = hass.data[DOMAIN].get(entry_agency) coordinator: NextBusDataUpdateCoordinator = hass.data[DOMAIN].get(coordinator_key)
async_add_entities( async_add_entities(
( (

View file

@ -41,7 +41,7 @@ import pytest
def route_config_direction(request: pytest.FixtureRequest) -> Any: def route_config_direction(request: pytest.FixtureRequest) -> Any:
"""Generate alternative directions values. """Generate alternative directions values.
When only on edirection is returned, it is not returned as a list, but instead an object. When only one direction is returned, it is not returned as a list, but instead an object.
""" """
return request.param return request.param
@ -75,42 +75,56 @@ def mock_nextbus_lists(
"hidden": False, "hidden": False,
"timestamp": "2024-06-23T03:06:58Z", "timestamp": "2024-06-23T03:06:58Z",
}, },
{
"id": "G",
"rev": 1057,
"title": "F Market & Wharves",
"description": "7am-10pm daily",
"color": "",
"textColor": "",
"hidden": False,
"timestamp": "2024-06-23T03:06:58Z",
},
] ]
instance.route_details.return_value = { def route_details_side_effect(agency: str, route: str) -> dict:
"id": "F", route = route.upper()
"rev": 1057, return {
"title": "F Market & Wharves", "id": route,
"description": "7am-10pm daily", "rev": 1057,
"color": "", "title": f"{route} Market & Wharves",
"textColor": "", "description": "7am-10pm daily",
"hidden": False, "color": "",
"boundingBox": {}, "textColor": "",
"stops": [ "hidden": False,
{ "boundingBox": {},
"id": "5184", "stops": [
"lat": 37.8071299, {
"lon": -122.41732, "id": "5184",
"name": "Jones St & Beach St", "lat": 37.8071299,
"code": "15184", "lon": -122.41732,
"hidden": False, "name": "Jones St & Beach St",
"showDestinationSelector": True, "code": "15184",
"directions": ["F_0_var1", "F_0_var0"], "hidden": False,
}, "showDestinationSelector": True,
{ "directions": ["F_0_var1", "F_0_var0"],
"id": "5651", },
"lat": 37.8071299, {
"lon": -122.41732, "id": "5651",
"name": "Jones St & Beach St", "lat": 37.8071299,
"code": "15651", "lon": -122.41732,
"hidden": False, "name": "Jones St & Beach St",
"showDestinationSelector": True, "code": "15651",
"directions": ["F_0_var1", "F_0_var0"], "hidden": False,
}, "showDestinationSelector": True,
], "directions": ["F_0_var1", "F_0_var0"],
"directions": route_config_direction, },
"paths": [], ],
"timestamp": "2024-06-23T03:06:58Z", "directions": route_config_direction,
} "paths": [],
"timestamp": "2024-06-23T03:06:58Z",
}
instance.route_details.side_effect = route_details_side_effect
return instance return instance

View file

@ -5,6 +5,7 @@ from copy import deepcopy
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from urllib.error import HTTPError from urllib.error import HTTPError
from freezegun.api import FrozenDateTimeFactory
from py_nextbus.client import NextBusFormatError, NextBusHTTPError from py_nextbus.client import NextBusFormatError, NextBusHTTPError
import pytest import pytest
@ -16,16 +17,21 @@ from homeassistant.const import CONF_NAME, CONF_STOP
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.helpers.update_coordinator import UpdateFailed
from tests.common import MockConfigEntry from tests.common import MockConfigEntry, async_fire_time_changed
VALID_AGENCY = "sfmta-cis" VALID_AGENCY = "sfmta-cis"
VALID_ROUTE = "F" VALID_ROUTE = "F"
VALID_STOP = "5184" VALID_STOP = "5184"
VALID_COORDINATOR_KEY = f"{VALID_AGENCY}-{VALID_STOP}"
VALID_AGENCY_TITLE = "San Francisco Muni" VALID_AGENCY_TITLE = "San Francisco Muni"
VALID_ROUTE_TITLE = "F-Market & Wharves" VALID_ROUTE_TITLE = "F-Market & Wharves"
VALID_STOP_TITLE = "Market St & 7th St" VALID_STOP_TITLE = "Market St & 7th St"
SENSOR_ID = "sensor.san_francisco_muni_f_market_wharves_market_st_7th_st" SENSOR_ID = "sensor.san_francisco_muni_f_market_wharves_market_st_7th_st"
ROUTE_2 = "G"
ROUTE_TITLE_2 = "G-Market & Wharves"
SENSOR_ID_2 = "sensor.san_francisco_muni_g_market_wharves_market_st_7th_st"
PLATFORM_CONFIG = { PLATFORM_CONFIG = {
sensor.DOMAIN: { sensor.DOMAIN: {
"platform": DOMAIN, "platform": DOMAIN,
@ -44,6 +50,14 @@ CONFIG_BASIC = {
} }
} }
CONFIG_BASIC_2 = {
DOMAIN: {
CONF_AGENCY: VALID_AGENCY,
CONF_ROUTE: ROUTE_2,
CONF_STOP: VALID_STOP,
}
}
BASIC_RESULTS = [ BASIC_RESULTS = [
{ {
"route": { "route": {
@ -60,7 +74,20 @@ BASIC_RESULTS = [
{"minutes": 3, "timestamp": 1553807373000}, {"minutes": 3, "timestamp": 1553807373000},
{"minutes": 10, "timestamp": 1553807380000}, {"minutes": 10, "timestamp": 1553807380000},
], ],
} },
{
"route": {
"title": ROUTE_TITLE_2,
"id": ROUTE_2,
},
"stop": {
"name": VALID_STOP_TITLE,
"id": VALID_STOP,
},
"values": [
{"minutes": 90, "timestamp": 1553807379000},
],
},
] ]
NO_UPCOMING = [ NO_UPCOMING = [
@ -74,7 +101,18 @@ NO_UPCOMING = [
"id": VALID_STOP, "id": VALID_STOP,
}, },
"values": [], "values": [],
} },
{
"route": {
"title": ROUTE_TITLE_2,
"id": ROUTE_2,
},
"stop": {
"name": VALID_STOP_TITLE,
"id": VALID_STOP,
},
"values": [],
},
] ]
@ -100,13 +138,15 @@ async def assert_setup_sensor(
hass: HomeAssistant, hass: HomeAssistant,
config: dict[str, dict[str, str]], config: dict[str, dict[str, str]],
expected_state=ConfigEntryState.LOADED, expected_state=ConfigEntryState.LOADED,
route_title: str = VALID_ROUTE_TITLE,
) -> MockConfigEntry: ) -> MockConfigEntry:
"""Set up the sensor and assert it's been created.""" """Set up the sensor and assert it's been created."""
unique_id = f"{config[DOMAIN][CONF_AGENCY]}_{config[DOMAIN][CONF_ROUTE]}_{config[DOMAIN][CONF_STOP]}"
config_entry = MockConfigEntry( config_entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
data=config[DOMAIN], data=config[DOMAIN],
title=f"{VALID_AGENCY_TITLE} {VALID_ROUTE_TITLE} {VALID_STOP_TITLE}", title=f"{VALID_AGENCY_TITLE} {route_title} {VALID_STOP_TITLE}",
unique_id=f"{VALID_AGENCY}_{VALID_ROUTE}_{VALID_STOP}", unique_id=unique_id,
) )
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
@ -153,7 +193,7 @@ async def test_prediction_exceptions(
) -> None: ) -> None:
"""Test that some coodinator exceptions raise UpdateFailed exceptions.""" """Test that some coodinator exceptions raise UpdateFailed exceptions."""
await assert_setup_sensor(hass, CONFIG_BASIC) await assert_setup_sensor(hass, CONFIG_BASIC)
coordinator: NextBusDataUpdateCoordinator = hass.data[DOMAIN][VALID_AGENCY] coordinator: NextBusDataUpdateCoordinator = hass.data[DOMAIN][VALID_COORDINATOR_KEY]
mock_nextbus_predictions.side_effect = client_exception mock_nextbus_predictions.side_effect = client_exception
with pytest.raises(UpdateFailed): with pytest.raises(UpdateFailed):
await coordinator._async_update_data() await coordinator._async_update_data()
@ -205,3 +245,54 @@ async def test_verify_no_upcoming(
assert state is not None assert state is not None
assert state.attributes["upcoming"] == "No upcoming predictions" assert state.attributes["upcoming"] == "No upcoming predictions"
assert state.state == "unknown" assert state.state == "unknown"
async def test_unload_entry(
hass: HomeAssistant,
mock_nextbus: MagicMock,
mock_nextbus_lists: MagicMock,
mock_nextbus_predictions: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that the sensor can be unloaded."""
config_entry1 = await assert_setup_sensor(hass, CONFIG_BASIC)
await assert_setup_sensor(hass, CONFIG_BASIC_2, route_title=ROUTE_TITLE_2)
# Verify the first sensor
state = hass.states.get(SENSOR_ID)
assert state is not None
assert state.state == "2019-03-28T21:09:31+00:00"
assert state.attributes["agency"] == VALID_AGENCY
assert state.attributes["route"] == VALID_ROUTE_TITLE
assert state.attributes["stop"] == VALID_STOP_TITLE
assert state.attributes["upcoming"] == "1, 2, 3, 10"
# Verify the second sensor
state = hass.states.get(SENSOR_ID_2)
assert state is not None
assert state.state == "2019-03-28T21:09:39+00:00"
assert state.attributes["agency"] == VALID_AGENCY
assert state.attributes["route"] == ROUTE_TITLE_2
assert state.attributes["stop"] == VALID_STOP_TITLE
assert state.attributes["upcoming"] == "90"
# Update mock to return new predictions
new_predictions = deepcopy(BASIC_RESULTS)
new_predictions[1]["values"] = [{"minutes": 5, "timestamp": 1553807375000}]
mock_nextbus_predictions.return_value = new_predictions
# Unload config entry 1
await hass.config_entries.async_unload(config_entry1.entry_id)
await hass.async_block_till_done()
assert config_entry1.state is ConfigEntryState.NOT_LOADED
# Skip ahead in time
freezer.tick(120)
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
# Check update for new predictions
state = hass.states.get(SENSOR_ID_2)
assert state is not None
assert state.attributes["upcoming"] == "5"
assert state.state == "2019-03-28T21:09:35+00:00"