From 56b4b96984f4b47438a59865b644589c85b43044 Mon Sep 17 00:00:00 2001 From: Simon White Date: Tue, 9 Jun 2026 09:30:25 +0100 Subject: [PATCH 1/4] feat: add live London Underground/DLR departures support via TfL Unified API --- CHANGELOG.md | 7 ++ README.md | 3 +- balena.yml | 11 ++- docs/01-getting-started.md | 2 +- docs/04-configuration.md | 26 +++++- src/config.py | 14 ++- src/main.py | 119 ++++++++++++++++++++++-- src/tube.py | 183 +++++++++++++++++++++++++++++++++++++ version.txt | 2 +- 9 files changed, 350 insertions(+), 17 deletions(-) create mode 100644 src/tube.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bce325e..7849045 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.8.0](https://github.com/chrisys/train-departure-display/compare/v0.7.0...v0.8.0) + + +### Features + +* add live London Underground/DLR departures via the TfL Unified API, selectable with the `mode` environment variable. Tube mode renders an Underground-style next-train board (arrival order, destination and a live `mins` countdown such as `2 mins`/`Due`) with a scrolling status line showing the full platform name and live train location, and shows a "Powered by Transport for London" startup screen. Configurable with `departureStation` (TfL StopPoint id), `tubeLine` and `tubeDirection`; no API key is required + ## [0.7.0](https://github.com/chrisys/train-departure-display/compare/v0.6.3...v0.7.0) (2026-04-21) 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..33eb91e 100644 --- a/balena.yml +++ b/balena.yml @@ -75,13 +75,18 @@ data: - raspberry-pi - raspberrypi0-2w-64 - raspberrypi3 + - raspberrypi4-64 applicationEnvironmentVariables: - TZ: Europe/London - - departureStation: PAD - - outOfHoursName: London Paddington + - mode: tube + - departureStation: 940GZZLUCTN + - outOfHoursName: Camden Town - numericPlatformsOnly: false - refreshTime: 120 - apiKey: UPDATE_ME + - tflAppKey: null + - tubeLine: Northern + - tubeDirection: southbound - operatingHours: 8-22 - screenBlankHours: 1-6 - screenRotation: 2 @@ -96,4 +101,4 @@ data: - targetFPS: 70 - fpsTime: 10 - debug: false -version: 0.7.0 +version: 0.8.0 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..d4274c1 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" @@ -261,8 +287,12 @@ def loadData(apiConfig, journeyConfig, config): rows = "10" try: - departures, stationName = loadDeparturesForStation( - journeyConfig, apiConfig["apiKey"], rows) + if config["mode"] == "tube": + departures, stationName = loadTubeDeparturesForStation( + journeyConfig, apiConfig["tflAppKey"], rows) + else: + departures, stationName = loadDeparturesForStation( + journeyConfig, apiConfig["apiKey"], rows) if departures is None: return False, False, stationName @@ -270,7 +300,8 @@ def loadData(apiConfig, journeyConfig, config): firstDepartureDestinations = departures[0]["calling_at_list"] return departures, firstDepartureDestinations, stationName 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'] @@ -282,12 +313,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: @@ -475,6 +512,66 @@ 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 + minsWidth = int(font.getlength("10 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) @@ -582,11 +679,17 @@ def getVersionDate(): 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["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']: screen1Data = platform_filter(departureData, config["journey"]["screen2Platform"], station, config["journey"]["numericPlatformsOnly"]) - virtual1 = drawSignage(device1, width=widgetWidth, height=widgetHeight, data=screen1Data, screen_id='screen2') + if config["mode"] == "tube": + virtual1 = drawSignageTube(device1, width=widgetWidth, height=widgetHeight, data=screen1Data, screen_id='screen2') + else: + virtual1 = drawSignage(device1, width=widgetWidth, height=widgetHeight, data=screen1Data, screen_id='screen2') timeAtStart = time.time() diff --git a/src/tube.py b/src/tube.py new file mode 100644 index 0000000..3ec1c1a --- /dev/null +++ b/src/tube.py @@ -0,0 +1,183 @@ +import re +from datetime import datetime, timezone + +import requests + +TFL_BASE_URL = "https://api.tfl.gov.uk" + + +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() + return directionFilter == direction or directionFilter in platformName + 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() + + Departures, departureStationName = ProcessTubeArrivals(journeyConfig, arrivals) + + if not departureStationName: + departureStationName = fetchStationName(stopPointId, appKey) or journeyConfig.get("outOfHoursName") or "" + + if Departures is None: + return None, departureStationName + + try: + maxRows = int(rows) + except (TypeError, ValueError): + maxRows = len(Departures) + + return Departures[:maxRows], departureStationName diff --git a/version.txt b/version.txt index faef31a..a3df0a6 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.7.0 +0.8.0 From 06c80f20fc822d6fc3bb52a30beb248629f3ec62 Mon Sep 17 00:00:00 2001 From: Simon White Date: Thu, 11 Jun 2026 09:18:32 +0100 Subject: [PATCH 2/4] chore: revert version to 0.7.0 and update changelog for consistency --- CHANGELOG.md | 7 ------- balena.yml | 13 ++++++------- src/main.py | 3 ++- version.txt | 2 +- 4 files changed, 9 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7849045..bce325e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,5 @@ # Changelog -## [0.8.0](https://github.com/chrisys/train-departure-display/compare/v0.7.0...v0.8.0) - - -### Features - -* add live London Underground/DLR departures via the TfL Unified API, selectable with the `mode` environment variable. Tube mode renders an Underground-style next-train board (arrival order, destination and a live `mins` countdown such as `2 mins`/`Due`) with a scrolling status line showing the full platform name and live train location, and shows a "Powered by Transport for London" startup screen. Configurable with `departureStation` (TfL StopPoint id), `tubeLine` and `tubeDirection`; no API key is required - ## [0.7.0](https://github.com/chrisys/train-departure-display/compare/v0.6.3...v0.7.0) (2026-04-21) diff --git a/balena.yml b/balena.yml index 33eb91e..cc0f1ba 100644 --- a/balena.yml +++ b/balena.yml @@ -75,18 +75,17 @@ data: - raspberry-pi - raspberrypi0-2w-64 - raspberrypi3 - - raspberrypi4-64 applicationEnvironmentVariables: - TZ: Europe/London - - mode: tube - - departureStation: 940GZZLUCTN - - outOfHoursName: Camden Town + - mode: nationalrail + - departureStation: PAD + - outOfHoursName: London Paddington - numericPlatformsOnly: false - refreshTime: 120 - apiKey: UPDATE_ME - tflAppKey: null - - tubeLine: Northern - - tubeDirection: southbound + - tubeLine: null + - tubeDirection: null - operatingHours: 8-22 - screenBlankHours: 1-6 - screenRotation: 2 @@ -101,4 +100,4 @@ data: - targetFPS: 70 - fpsTime: 10 - debug: false -version: 0.8.0 +version: 0.7.0 diff --git a/src/main.py b/src/main.py index d4274c1..b49fb01 100644 --- a/src/main.py +++ b/src/main.py @@ -528,7 +528,8 @@ def drawSignageTube(device, width, height, data, screen_id='default'): return noTrains # reserve space on the right for the longest expected countdown - minsWidth = int(font.getlength("10 mins")) + # (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 diff --git a/version.txt b/version.txt index a3df0a6..faef31a 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.8.0 +0.7.0 From 306309e51994e3064b581bb3fcf5dd1fc484905c Mon Sep 17 00:00:00 2001 From: Simon White Date: Sat, 13 Jun 2026 11:24:37 +0100 Subject: [PATCH 3/4] Fix: Remove screen flashing on data refresh by caching viewports - Only recreate viewports when data actually changes - Reuse existing viewport between refresh cycles - Separates data refresh from rendering for smooth animations - Includes tube disruption message support and direction filtering --- src/main.py | 87 ++++++++++++++++++++++++++++++++++++----------------- src/tube.py | 74 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 131 insertions(+), 30 deletions(-) diff --git a/src/main.py b/src/main.py index b49fb01..597c3ba 100644 --- a/src/main.py +++ b/src/main.py @@ -280,7 +280,7 @@ 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 @@ -288,22 +288,23 @@ def loadData(apiConfig, journeyConfig, config): try: if config["mode"] == "tube": - departures, stationName = loadTubeDeparturesForStation( + 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: 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): @@ -390,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)) @@ -402,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) @@ -649,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]): @@ -669,34 +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"]) - if config["mode"] == "tube": - virtual = drawSignageTube(device, width=widgetWidth, height=widgetHeight, data=screenData, screen_id='screen1') + # 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: - 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"]) + departureData = data[0] + nextStations = data[1] + station = data[2] + screenData = platform_filter(departureData, config["journey"]["screen1Platform"], station, config["journey"]["numericPlatformsOnly"]) if config["mode"] == "tube": - virtual1 = drawSignageTube(device1, width=widgetWidth, height=widgetHeight, data=screen1Data, screen_id='screen2') + virtual = drawSignageTube(device, width=widgetWidth, height=widgetHeight, data=screenData, screen_id='screen1') else: - virtual1 = drawSignage(device1, width=widgetWidth, height=widgetHeight, data=screen1Data, screen_id='screen2') + 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 index 3ec1c1a..80f368d 100644 --- a/src/tube.py +++ b/src/tube.py @@ -6,6 +6,74 @@ 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] @@ -167,17 +235,19 @@ def loadTubeDeparturesForStation(journeyConfig, appKey, rows): 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 + return None, departureStationName, disruptionMessage try: maxRows = int(rows) except (TypeError, ValueError): maxRows = len(Departures) - return Departures[:maxRows], departureStationName + return Departures[:maxRows], departureStationName, disruptionMessage From 0a03aae6e757dfb342cfe82c6a52f896df55540d Mon Sep 17 00:00:00 2001 From: Simon White Date: Sat, 13 Jun 2026 11:34:51 +0100 Subject: [PATCH 4/4] Fix: Map compass direction filters to TfL API inbound/outbound values - tubeDirection='westbound' now correctly maps to direction='inbound' in API - tubeDirection='eastbound' maps to direction='outbound' - Fixes issue where compass direction filters were never matching - When westbound service is available, it will now display correctly --- src/tube.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/tube.py b/src/tube.py index 80f368d..6e44b6a 100644 --- a/src/tube.py +++ b/src/tube.py @@ -151,7 +151,24 @@ def ProcessTubeArrivals(journeyConfig, arrivals): def matchesDirection(arrival): direction = (arrival.get("direction") or "").lower() platformName = (arrival.get("platformName") or "").lower() - return directionFilter == direction or directionFilter in platformName + 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: