diff --git a/README.md b/README.md index 5acd67f..702da3b 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ ![](assets/full-logo.png) -**A replica, near real-time, miniature UK railway station train departure sign based upon a Raspberry Pi Zero and 256x64 SPI OLED display(s). Uses the publicly available [OpenLDBWS API by National Rail Enquiries](https://www.nationalrail.co.uk/).** +**A replica, near real-time, miniature UK railway station train departure sign based upon a Raspberry Pi Zero and 256x64 SPI OLED display(s). Uses the publicly available [OpenLDBWS API by National Rail Enquiries](https://www.nationalrail.co.uk/) and the [TfL Unified API](https://api.tfl.gov.uk/) for London Underground / DLR arrivals.** ## Highlights - **See local departures**: Display the depatures from your local station at home for up to date train information. +- **London Underground & DLR**: Switch to `tube` mode to show live TfL arrivals — no API key required. - **3D-printable cases**: Print your own miniature case to keep everything tidy - both desktop and 'hanging' style available. - **Dual display support**: Run two displays each showing departures from different platforms from a single Raspberry Pi. diff --git a/balena.yml b/balena.yml index 8e0a8e7..cc0f1ba 100644 --- a/balena.yml +++ b/balena.yml @@ -77,11 +77,15 @@ data: - raspberrypi3 applicationEnvironmentVariables: - TZ: Europe/London + - mode: nationalrail - departureStation: PAD - outOfHoursName: London Paddington - numericPlatformsOnly: false - refreshTime: 120 - apiKey: UPDATE_ME + - tflAppKey: null + - tubeLine: null + - tubeDirection: null - operatingHours: 8-22 - screenBlankHours: 1-6 - screenRotation: 2 diff --git a/docs/01-getting-started.md b/docs/01-getting-started.md index 2cf2d10..c2af1a6 100644 --- a/docs/01-getting-started.md +++ b/docs/01-getting-started.md @@ -33,7 +33,7 @@ This project is designed to run using a Raspberry Pi Zero W or Zero 2 W and ther - Software to flash an SD card ([balenaEtcher](https://balena.io/etcher)) - A free [balenaCloud](https://balena.io/cloud) account -- A free [National Rail Enquiries OpenLDBWS API](http://realtime.nationalrail.co.uk/OpenLDBWSRegistration) account +- A free [National Rail Enquiries OpenLDBWS API](http://realtime.nationalrail.co.uk/OpenLDBWSRegistration) account (only needed for National Rail departures; not required if you only want London Underground / DLR arrivals) - (optional) A download of this project - (optional) The [balena CLI tools](https://github.com/balena-io/balena-cli/blob/master/INSTALL.md) diff --git a/docs/04-configuration.md b/docs/04-configuration.md index 82f3c64..5b574b3 100644 --- a/docs/04-configuration.md +++ b/docs/04-configuration.md @@ -9,9 +9,10 @@ These environment variables are specified using the [balenaCloud dashboard](http | Key | Example Value |----------------------------------|---------- -|`apiKey` **(REQUIRED)** | `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` (OpenLDBWS API key) +|`apiKey` **(REQUIRED for National Rail)** | `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` (OpenLDBWS API key) +|`mode` | `nationalrail` (default) for National Rail departures, or `tube` for live London Underground / DLR arrivals from the TfL Unified API |`TZ` | `Europe/London`, will default to UTC if not set ([timezones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)) -|`departureStation` | `PAD` ([station code](https://www.nationalrail.co.uk/stations_destinations/48541.aspx)) +|`departureStation` | `PAD` ([station code](https://www.nationalrail.co.uk/stations_destinations/48541.aspx)) in National Rail mode, or a TfL StopPoint / Naptan id such as `940GZZLUKSX` in tube mode |`destinationStation` | `HWV` ([station code](https://www.nationalrail.co.uk/stations_destinations/48541.aspx)) [optional] Filters trains shown to only those that call at this station. This can be multiple stations seperated by a comma. eg. PAD, KGX, STP |`timeOffset` | `5` [optional] (Time offset, in minutes, for the departure board. Can be used to see into the future (positive value) or past (negative value). Set 5 if you live 5 min from the station and want to hide departures that are too soon to catch) |`refreshTime` | `120` (seconds between data refresh) @@ -31,6 +32,27 @@ These environment variables are specified using the [balenaCloud dashboard](http | `targetFPS` | `20` (Frame rate regulator FPS target; 0 disables the regulator, which will increase FPS on constrained CPU, but will run the CPU hot at 100%.) | `debug` | `False` (Display debugging information; `True` shows the debug info permanently, any integer `>1` will show instead of the splash screen for that number of seconds) +## London Underground / DLR (tube mode) + +Set `mode` to `tube` to show live London Underground, DLR, London Overground and Elizabeth line arrivals from the free [TfL Unified API](https://api.tfl.gov.uk/) instead of National Rail departures. The `apiKey` is not required in this mode. + +| Key | Example Value +|-----|---------- +| `mode` | `tube` +| `departureStation` | `940GZZLUKSX` (a TfL StopPoint / Naptan id; look one up via the [TfL StopPoint search](https://api.tfl.gov.uk/StopPoint/Search/King%27s%20Cross)) +| `tflAppKey` | `xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` [optional] a TfL application key, which raises the API rate limits. Not required, but recommended for always-on displays +| `tubeLine` | `Northern` [optional] restricts the board to a single line, since a station can serve several. The value matches the TfL line name, so it also works for DLR (`DLR`), the Elizabeth line (`Elizabeth line`) and the newer named Overground lines (`Mildmay`, `Windrush`, `Lioness`, `Suffragette`, `Weaver`, `Liberty`) +| `tubeDirection` | `southbound` [optional] restricts the board to a single direction. Accepts the TfL direction (`inbound` / `outbound`) or a compass word (`westbound` / `eastbound` / `northbound` / `southbound`). For example, at Camden Town (`940GZZLUCTN`) on the `Northern` line, `southbound` shows both branches interleaved by arrival time — trains towards `Morden via Bank` alongside `Kennington via CX` and `Battersea via CX` (CX = Charing Cross) + +Notes for tube mode: + +- TfL provides live arrival predictions rather than a fixed timetable, so departures are ordered by how soon each train arrives and all services show as `On time`. +- The Underground does not expose downstream calling points, so the scrolling line beneath the first departure shows its platform and live train location instead (e.g. "Southbound - Platform 2 -- Between Camden Town and Euston"). +- **Branch lines** (e.g. Northern, District, Metropolitan): both branches are shown interleaved in arrival order and are told apart by their destination text (e.g. "Morden via Bank" vs "Morden via Charing Cross"). Leave `tubeDirection` set to a single value to keep both branches. +- **DLR, London Overground and the Elizabeth line**: their platforms are not labelled with a compass direction (you'll see `Platform 1`, `Platform Unknown` etc.), so a compass `tubeDirection` such as `southbound` will match nothing and leave the board blank. Use `inbound` / `outbound` instead, or leave `tubeDirection` blank. For example, Acton Central (`910GACTNCTL`) on the `Mildmay` line reports `Platform 1` / `Platform 2` with `outbound` trains towards Richmond and `inbound` trains towards Willesden Junction. +- **Circle line** (and other loop / terminating services): TfL often reports no direction at all for these, so any `tubeDirection` filter will hide them. Leave `tubeDirection` blank on the Circle line. +- `destinationStation` filtering and `timeOffset` only apply to National Rail mode. + If using two screens the following line needs to be added into /boot/config.txt which is achieved by using the 'Define DT overlays' option within the Device configuration screen on balenaCloud: `spi1-3cs` ![](images/overlays.png) diff --git a/src/config.py b/src/config.py index 25de8e8..c492b75 100644 --- a/src/config.py +++ b/src/config.py @@ -40,6 +40,9 @@ def loadConfig(): data["firstDepartureBold"] = False data["hoursPattern"] = re.compile("^((2[0-3]|[0-1]?[0-9])-(2[0-3]|[0-1]?[0-9]))$") + # data source mode: "nationalrail" (default) or "tube" (TfL Underground/DLR live arrivals) + data["mode"] = (os.getenv("mode") or "nationalrail").strip().lower() + data["journey"]["departureStation"] = os.getenv("departureStation") or "PAD" data["journey"]["destinationStation"] = os.getenv("destinationStation") or "" @@ -58,8 +61,17 @@ def loadConfig(): data["journey"]["screen1Platform"] = parsePlatformData(os.getenv("screen1Platform")) data["journey"]["screen2Platform"] = parsePlatformData(os.getenv("screen2Platform")) data["journey"]["numericPlatformsOnly"] = os.getenv("numericPlatformsOnly", "False").lower() == "true" - + + # optional: restrict tube mode to a single line (e.g. "Victoria"), as a stop can serve several + data["journey"]["tubeLine"] = os.getenv("tubeLine") or "" + + # optional: restrict tube mode to one direction. Accepts the TfL direction + # ("inbound"/"outbound") or a compass word ("westbound"/"eastbound"/etc.) + data["journey"]["tubeDirection"] = os.getenv("tubeDirection") or "" + data["api"]["apiKey"] = os.getenv("apiKey") or None + # optional TfL application key (raises rate limits in tube mode; not required) + data["api"]["tflAppKey"] = os.getenv("tflAppKey") or None data["api"]["operatingHours"] = os.getenv("operatingHours") or "" data["showDepartureNumbers"] = False diff --git a/src/main.py b/src/main.py index cca9234..597c3ba 100644 --- a/src/main.py +++ b/src/main.py @@ -7,6 +7,7 @@ from PIL import ImageFont, Image, ImageDraw from trains import loadDeparturesForStation +from tube import loadTubeDeparturesForStation from config import loadConfig from open import isRun @@ -79,6 +80,23 @@ def drawText(draw, *_): return drawText +def renderTubeDestination(departure, font, order): + def drawText(draw, *_): + destinationName = departure["destination_name"] + text = f"{order} {destinationName}" + _, _, bitmap = cachedBitmapText(text, font) + draw.bitmap((0, 0), bitmap, fill="yellow") + return drawText + + +def renderTubeMins(departure): + def drawText(draw, width, *_): + text = departure.get("mins_display", "") + w, _, bitmap = cachedBitmapText(text, font) + draw.bitmap((width - w, 0), bitmap, fill="yellow") + return drawText + + def renderCallingAt(draw, *_): stations = "Calling at: " _, _, bitmap = cachedBitmapText(stations, font) @@ -152,7 +170,7 @@ def drawText(draw, *_): else: # slide the bitmap up from the bottom of its viewport until it's fully in view draw.bitmap((0, txt_height - state.pixelsUp), bitmap, fill="yellow") - if state.pixelsUp == txt_height: + if state.pixelsUp >= txt_height: state.pauseCount += 1 if state.pauseCount > 20: state.hasElevated = 1 @@ -221,6 +239,14 @@ def drawText(draw, *_): return drawText +def renderTfL(xOffset): + def drawText(draw, *_): + text = "Transport for London" + draw.text((int(xOffset), 0), text=text, font=fontBold, fill="yellow") + + return drawText + + def renderName(xOffset): def drawText(draw, *_): text = "UK Train Departure Display" @@ -254,25 +280,31 @@ def loadData(apiConfig, journeyConfig, config): runHours = [int(x) for x in apiConfig['operatingHours'].split('-')] if len(runHours) == 2 and isRun(runHours[0], runHours[1]) is False: - return False, False, journeyConfig['outOfHoursName'] + return False, False, journeyConfig['outOfHoursName'], "" # set rows to 10 (max allowed) to get as many departure as poss # leaving as a variable so this can be updated if the API does rows = "10" try: - departures, stationName = loadDeparturesForStation( - journeyConfig, apiConfig["apiKey"], rows) + if config["mode"] == "tube": + departures, stationName, disruptionMessage = loadTubeDeparturesForStation( + journeyConfig, apiConfig["tflAppKey"], rows) + else: + disruptionMessage = "" + departures, stationName = loadDeparturesForStation( + journeyConfig, apiConfig["apiKey"], rows) if departures is None: - return False, False, stationName + return False, False, stationName, disruptionMessage firstDepartureDestinations = departures[0]["calling_at_list"] - return departures, firstDepartureDestinations, stationName + return departures, firstDepartureDestinations, stationName, disruptionMessage except requests.RequestException as err: - print("Error: Failed to fetch data from OpenLDBWS") + source = "TfL" if config["mode"] == "tube" else "OpenLDBWS" + print("Error: Failed to fetch data from " + source) print(err.__context__) - return False, False, journeyConfig['outOfHoursName'] + return False, False, journeyConfig['outOfHoursName'], "" def drawStartup(device, width, height): @@ -282,12 +314,18 @@ def drawStartup(device, width, height): nameSize = int(fontBold.getlength("UK Train Departure Display")) versionSize = int(font.getlength("v" + getVersionNumber().strip() + " " + getVersionDate())) poweredSize = int(fontBold.getlength("Powered by")) - NRESize = int(fontBold.getlength("National Rail Enquiries")) + + if config["mode"] == "tube": + providerSize = int(fontBold.getlength("Transport for London")) + renderProvider = renderTfL + else: + providerSize = int(fontBold.getlength("National Rail Enquiries")) + renderProvider = renderNRE rowOne = snapshot(width, 10, renderName((width - nameSize) / 2), interval=10) rowTwo = snapshot(width, 10, renderVersion((width - versionSize) / 2), interval=10) rowThree = snapshot(width, 10, renderPoweredBy((width - poweredSize) / 2), interval=10) - rowFour = snapshot(width, 10, renderNRE((width - NRESize) / 2), interval=10) + rowFour = snapshot(width, 10, renderProvider((width - providerSize) / 2), interval=10) if len(virtualViewport._hotspots) > 0: for hotspot, xy in virtualViewport._hotspots: @@ -353,7 +391,7 @@ def drawDebugScreen(device, width, height, screen="1", showTime=False): -def drawBlankSignage(device, width, height, departureStation): +def drawBlankSignage(device, width, height, departureStation, serviceMessage=""): welcomeSize = int(fontBold.getlength("Welcome to")) stationSize = int(fontBold.getlength(departureStation)) @@ -365,7 +403,17 @@ def drawBlankSignage(device, width, height, departureStation): (width - welcomeSize) / 2), interval=config["refreshTime"]) rowTwo = snapshot(width, 10, renderDepartureStation( departureStation, (width - stationSize) / 2), interval=config["refreshTime"]) - rowThree = snapshot(width, 10, renderDots, interval=config["refreshTime"]) + + if serviceMessage: + rowThree = snapshot( + width, + 10, + renderStations("Service update: " + serviceMessage, "blank-status"), + interval=0.02, + ) + else: + rowThree = snapshot(width, 10, renderDots, interval=config["refreshTime"]) + # this will skip a second sometimes if set to 1, but a hotspot burns CPU # so set to snapshot of 0.1; you won't notice rowTime = snapshot(width, 14, renderTime, interval=0.1) @@ -475,6 +523,67 @@ def drawSignage(device, width, height, data, screen_id='default'): return virtualViewport +def drawSignageTube(device, width, height, data, screen_id='default'): + """London Underground / DLR style board: arrival order + destination on the + left and a 'mins' countdown on the right. The first departure has a scrolling + status line beneath it showing its platform and live location (mirroring the + National Rail 'calling at' scroller), followed by two more departures and the clock.""" + virtualViewport = viewport(device, width=width, height=height) + + departures, _, departureStation = data + + width = virtualViewport.width + + if len(departures) == 0: + noTrains = drawBlankSignage(device, width=width, height=height, departureStation=departureStation) + return noTrains + + # reserve space on the right for the longest expected countdown + # (TfL predictions can run 20-30 minutes out, so measure a wide two-digit value) + minsWidth = int(font.getlength("88 mins")) + gap = 5 + destWidth = width - minsWidth - gap + + firstFont = font + if config['firstDepartureBold']: + firstFont = fontBold + + # build the scrolling status line for the first departure + first = departures[0] + statusParts = [p for p in [first.get("platform_name", ""), first.get("current_location", "")] if p] + statusLine = " -- ".join(statusParts) + + hotspots = [] + + # row 1: first departure + hotspots.append((snapshot(destWidth, 10, renderTubeDestination(first, firstFont, 1), interval=config["refreshTime"]), (0, 0))) + hotspots.append((snapshot(minsWidth, 10, renderTubeMins(first), interval=config["refreshTime"]), (width - minsWidth, 0))) + + # row 2: scrolling platform + live location for the first departure + hotspots.append((snapshot(width, 10, renderStations(statusLine, screen_id), interval=0.02), (0, 12))) + + # rows 3 & 4: the next two departures + nextYPositions = [24, 36] + for idx, y in enumerate(nextYPositions): + depIndex = idx + 1 + if depIndex >= len(departures): + break + dep = departures[depIndex] + hotspots.append((snapshot(destWidth, 10, renderTubeDestination(dep, font, depIndex + 1), interval=config["refreshTime"]), (0, y))) + hotspots.append((snapshot(minsWidth, 10, renderTubeMins(dep), interval=config["refreshTime"]), (width - minsWidth, y))) + + rowTime = snapshot(width, 14, renderTime, interval=0.1) + + if len(virtualViewport._hotspots) > 0: + for vhotspot, xy in virtualViewport._hotspots: + virtualViewport.remove_hotspot(vhotspot, xy) + + for hotspot, pos in hotspots: + virtualViewport.add_hotspot(hotspot, pos) + virtualViewport.add_hotspot(rowTime, (0, 50)) + + return virtualViewport + def getIp(): s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.settimeout(1) @@ -551,6 +660,13 @@ def getVersionDate(): if config['hoursPattern'].match(config['screenBlankHours']): blankHours = [int(x) for x in config['screenBlankHours'].split('-')] + # Cache variables to avoid recreating viewports unnecessarily + virtual = None + virtual1 = None + last_data = None + last_data1 = None + last_blank_state = None + while True: with regulator: if len(blankHours) == 2 and isRun(blankHours[0], blankHours[1]): @@ -571,28 +687,47 @@ def getVersionDate(): virtual1 = drawDebugScreen(device1, width=widgetWidth, height=widgetHeight, showTime=True, screen="2") else: data = loadData(config["api"], config["journey"], config) - if data[0] is False: - virtual = drawBlankSignage( - device, width=widgetWidth, height=widgetHeight, departureStation=data[2]) - if config['dualScreen']: - virtual1 = drawBlankSignage( - device1, width=widgetWidth, height=widgetHeight, departureStation=data[2]) - else: - departureData = data[0] - nextStations = data[1] - station = data[2] - screenData = platform_filter(departureData, config["journey"]["screen1Platform"], station, config["journey"]["numericPlatformsOnly"]) - virtual = drawSignage(device, width=widgetWidth, height=widgetHeight, data=screenData, screen_id='screen1') - - if config['dualScreen']: - screen1Data = platform_filter(departureData, config["journey"]["screen2Platform"], station, config["journey"]["numericPlatformsOnly"]) - virtual1 = drawSignage(device1, width=widgetWidth, height=widgetHeight, data=screen1Data, screen_id='screen2') + # Only recreate viewport if data has changed + if data != last_data: + last_data = data + if data[0] is False: + virtual = drawBlankSignage( + device, width=widgetWidth, height=widgetHeight, departureStation=data[2], serviceMessage=data[3]) + else: + departureData = data[0] + nextStations = data[1] + station = data[2] + screenData = platform_filter(departureData, config["journey"]["screen1Platform"], station, config["journey"]["numericPlatformsOnly"]) + if config["mode"] == "tube": + virtual = drawSignageTube(device, width=widgetWidth, height=widgetHeight, data=screenData, screen_id='screen1') + else: + virtual = drawSignage(device, width=widgetWidth, height=widgetHeight, data=screenData, screen_id='screen1') + + if config['dualScreen']: + if data[0] is False: + data1 = (data[0], data[1], data[2], data[3]) + else: + departureData = data[0] + station = data[2] + data1 = platform_filter(departureData, config["journey"]["screen2Platform"], station, config["journey"]["numericPlatformsOnly"]) + + if data1 != last_data1: + last_data1 = data1 + if data[0] is False: + virtual1 = drawBlankSignage( + device1, width=widgetWidth, height=widgetHeight, departureStation=data[2], serviceMessage=data[3]) + else: + if config["mode"] == "tube": + virtual1 = drawSignageTube(device1, width=widgetWidth, height=widgetHeight, data=data1, screen_id='screen2') + else: + virtual1 = drawSignage(device1, width=widgetWidth, height=widgetHeight, data=data1, screen_id='screen2') timeAtStart = time.time() timeNow = time.time() - virtual.refresh() - if config['dualScreen']: + if virtual is not None: + virtual.refresh() + if config['dualScreen'] and virtual1 is not None: virtual1.refresh() except KeyboardInterrupt: diff --git a/src/tube.py b/src/tube.py new file mode 100644 index 0000000..6e44b6a --- /dev/null +++ b/src/tube.py @@ -0,0 +1,270 @@ +import re +from datetime import datetime, timezone + +import requests + +TFL_BASE_URL = "https://api.tfl.gov.uk" + + +def normalizeLineId(lineName): + """Best-effort mapping from a display line name to TfL line id""" + if not lineName: + return "" + cleaned = lineName.strip().lower() + if cleaned.endswith(" line"): + cleaned = cleaned[:-5].strip() + return cleaned.replace(" ", "-") + + +def extractLineIds(journeyConfig, arrivals): + """Choose relevant line ids for disruption lookups""" + lineFilter = (journeyConfig.get("tubeLine") or "").strip().lower() + lineIds = [] + + for arrival in arrivals: + lineName = (arrival.get("lineName") or "").strip().lower() + if lineFilter and lineName != lineFilter: + continue + lineId = (arrival.get("lineId") or "").strip().lower() + if lineId and lineId not in lineIds: + lineIds.append(lineId) + + if not lineIds and lineFilter: + fallback = normalizeLineId(lineFilter) + if fallback: + lineIds.append(fallback) + + return lineIds + + +def fetchTubeDisruptionMessage(journeyConfig, arrivals, appKey): + """Fetch the first non-good-service status reason for the configured/active lines""" + lineIds = extractLineIds(journeyConfig, arrivals) + if not lineIds: + return "" + + params = {} + if appKey: + params["app_key"] = appKey + + try: + response = requests.get( + TFL_BASE_URL + "/Line/" + ",".join(lineIds) + "/Status", + params=params, + timeout=10, + ) + response.raise_for_status() + lines = response.json() + except (requests.RequestException, ValueError): + return "" + + for line in lines: + lineName = (line.get("name") or "").strip() + for status in line.get("lineStatuses") or []: + severity = (status.get("statusSeverityDescription") or "").strip() + if severity.lower() == "good service": + continue + reason = (status.get("reason") or "").strip() + if reason: + # Keep this compact enough to remain readable on the OLED scroller. + return reason[:220] + if lineName and severity: + return (lineName + ": " + severity)[:220] + + return "" + + +def removeBrackets(originalName): + return re.split(r" \(", originalName)[0] + + +def cleanStationName(name): + """Tidy up a TfL station/destination name for the display""" + if name is None: + return "" + name = removeBrackets(name) + # remove the trailing "Underground Station" / "Rail Station" / "DLR Station" suffix + name = re.sub(r"\s+(Underground|Rail|DLR)?\s*Station$", "", name).strip() + return name + + +def extractPlatform(platformName): + """Pull a numeric platform out of a TfL platformName like 'Northbound - Platform 2'""" + if not platformName: + return None + match = re.search(r"Platform\s+(\d+)", platformName, re.IGNORECASE) + if match: + return match.group(1) + return None + + +def parseExpectedArrival(value): + """Convert a TfL ISO8601 timestamp into a local HH:MM string""" + if not value: + return None + # TfL returns e.g. '2026-06-08T14:32:05Z' + try: + cleaned = value.replace("Z", "+00:00") + dt = datetime.fromisoformat(cleaned) + except ValueError: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + # convert to the host's local time (set by the TZ env var) + return dt.astimezone().strftime("%H:%M") + + +def formatMins(seconds): + """Format a TfL timeToStation (seconds) as an Underground-style countdown""" + if seconds is None: + return "" + mins = int(seconds // 60) + if mins <= 0: + return "Due" + if mins == 1: + return "1 min" + return str(mins) + " mins" + + +def ProcessTubeArrivals(journeyConfig, arrivals): + lineFilter = journeyConfig.get("tubeLine") or "" + lineFilter = lineFilter.strip().lower() + + directionFilter = journeyConfig.get("tubeDirection") or "" + directionFilter = directionFilter.strip().lower() + + departureStationName = journeyConfig.get("outOfHoursName") or "" + + if not arrivals: + return None, departureStationName + + # use the station name reported by the API + departureStationName = cleanStationName(arrivals[0].get("stationName")) or departureStationName + + # optionally filter to a single line (a stop can serve several lines) + if lineFilter: + arrivals = [a for a in arrivals if (a.get("lineName") or "").lower() == lineFilter] + + # optionally filter by direction, matching either the API direction value + # ("inbound"/"outbound") or a compass word in the platform name ("westbound" etc.) + if directionFilter: + def matchesDirection(arrival): + direction = (arrival.get("direction") or "").lower() + platformName = (arrival.get("platformName") or "").lower() + filter_lower = directionFilter.lower() + + # Direct matches first + if filter_lower in platformName or filter_lower == direction: + return True + + # Map compass directions to TfL's inbound/outbound API values + if filter_lower in ["westbound", "west"]: + return direction == "inbound" + elif filter_lower in ["eastbound", "east"]: + return direction == "outbound" + elif filter_lower in ["northbound", "north"]: + return direction == "outbound" + elif filter_lower in ["southbound", "south"]: + return direction == "inbound" + + return False + + arrivals = [a for a in arrivals if matchesDirection(a)] + + if not arrivals: + return None, departureStationName + + # TfL predictions are frequency based; order by soonest to arrive + arrivals = sorted(arrivals, key=lambda a: a.get("timeToStation", 0)) + + Departures = [] + for arrival in arrivals: + thisDeparture = {} + + # skip predictions with no usable arrival time + if parseExpectedArrival(arrival.get("expectedArrival")) is None: + continue + + # Underground-style countdown (e.g. "2 mins", "Due") + thisDeparture["mins_display"] = formatMins(arrival.get("timeToStation")) + + platform = extractPlatform(arrival.get("platformName")) + if platform is not None: + thisDeparture["platform"] = platform + + # full platform name and live location for the scrolling status line + thisDeparture["platform_name"] = (arrival.get("platformName") or "").strip() + thisDeparture["current_location"] = (arrival.get("currentLocation") or "").strip() + + # prefer "towards" to keep branch info (e.g. "Morden via Bank"), falling + # back to destinationName when it's empty (e.g. DLR / Overground / Elizabeth line) + destinationName = cleanStationName( + arrival.get("towards") or arrival.get("destinationName") + ) + thisDeparture["destination_name"] = destinationName + + # the Underground exposes no calling points; kept only because the + # shared rendering helpers expect this key to be present + thisDeparture["calling_at_list"] = "" + + Departures.append(thisDeparture) + + if not Departures: + return None, departureStationName + + return Departures, departureStationName + + +def fetchStationName(stopPointId, appKey): + """Look up the friendly common name for a StopPoint (used when there are no arrivals)""" + try: + params = {} + if appKey: + params["app_key"] = appKey + response = requests.get( + TFL_BASE_URL + "/StopPoint/" + stopPointId, + params=params, + timeout=10, + ) + response.raise_for_status() + data = response.json() + return cleanStationName(data.get("commonName")) + except (requests.RequestException, ValueError): + return "" + + +def loadTubeDeparturesForStation(journeyConfig, appKey, rows): + if journeyConfig["departureStation"] == "": + raise ValueError( + "Please configure the departureStation environment variable") + + stopPointId = journeyConfig["departureStation"] + + params = {} + if appKey: + params["app_key"] = appKey + + response = requests.get( + TFL_BASE_URL + "/StopPoint/" + stopPointId + "/Arrivals", + params=params, + timeout=10, + ) + response.raise_for_status() + arrivals = response.json() + + disruptionMessage = fetchTubeDisruptionMessage(journeyConfig, arrivals, appKey) + + Departures, departureStationName = ProcessTubeArrivals(journeyConfig, arrivals) + + if not departureStationName: + departureStationName = fetchStationName(stopPointId, appKey) or journeyConfig.get("outOfHoursName") or "" + + if Departures is None: + return None, departureStationName, disruptionMessage + + try: + maxRows = int(rows) + except (TypeError, ValueError): + maxRows = len(Departures) + + return Departures[:maxRows], departureStationName, disruptionMessage