diff --git a/.gitignore b/.gitignore index e1cc106..cf7e20e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ ._DS_Store +src/__pycache__ diff --git a/CHANGELOG.md b/CHANGELOG.md index 921a3e4..0097fc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file automatically by Versionist. DO NOT EDIT THIS FILE MANUALLY! This project adheres to [Semantic Versioning](http://semver.org/). +# v0.5.0 +## (2023-06-29) + +* Upgrade: Switch Python version and base OS: Python 3.11 on Alpine +* New feature: `showDepartureNumbers` option - Adds 1st / 2nd / 3rd prefix as per UK train departures +* New feature: `firstDepartureBold` option - toggle bold of first departure line as this is regional +* New feature: `targetFPS` option - configurable FPS regulator (zero to disable) +* Development UX: `fpsTime` option - Adjusts how frequently the Effecive FPS is displayed +* Development UX: `headless` option - Run using emulated serial port (Useful for optimisation checks) +* Development UX: Skip NRE attribution sleep in emulation mode +* Development UX: Simplify Dockerfile slightly in an attempt to be Balena-y +* Performance: Seconds now render every 0.1 second, rather than a hotspot (reduce CPU) +* Performance: All "in-loop" TTF font rendering is now cached (reduce CPU) +* Fix: screen1Platform/screen2Platform being required incorrectly on the env + # v0.4.0 ## (2023-02-18) diff --git a/Dockerfile b/Dockerfile index d456da6..b6c8a41 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,32 +1,31 @@ -FROM balenalib/raspberry-pi-debian-python:3.7-buster-run AS builder +FROM balenalib/raspberry-pi-alpine-python:3.11.2-3.15-build as builder WORKDIR /usr/src/app -RUN mkdir -p /usr/src/debian-rootfs - -RUN install_packages apt-rdepends - -RUN apt-get update && \ - apt-get download \ - $(apt-rdepends tzdata python3 libopenjp2-7 libfreetype6-dev libjpeg-dev libtiff5 libxcb1 | grep -v "^ " | sed 's/debconf-2.0/debconf/g' | sed 's/^libc-dev$/libc6-dev/g' | sed 's/^libz-dev$/zlib1g-dev/g') - -RUN for pkg in *.deb; \ - do dpkg-deb -x $pkg /usr/src/debian-rootfs; \ - done +# Shared libraries +RUN apk add freetype-dev libjpeg-turbo-dev +# Install the required python packages, and save the compiled result to an output folder +# This requires gcc/etc which is why we do it in the build image and save the result for the run image COPY ./requirements.txt . -RUN pip install -t /usr/src/python-packages -r requirements.txt --no-cache-dir --extra-index-url=https://www.piwheels.org/simple - +RUN pip install --target=/usr/src/python-packages -r requirements.txt --no-cache-dir --config-settings="pillow=--disable-zlib" -FROM busybox:stable +# Grab the "run" image for the device, which is much lighter weight +FROM balenalib/raspberry-pi-alpine-python:3.11.2-3.15-run - -COPY --from=builder /usr/src/debian-rootfs ./ +# Copy in the compiled packages COPY --from=builder /usr/src/python-packages/ /usr/src/python-packages/ -COPY VERSION ./ +# Shared libraries +RUN apk add freetype-dev libjpeg-turbo-dev +# And the app +WORKDIR /usr/src/app COPY src ./src -ENV PYTHONPATH=/usr/src/python-packages/ +COPY VERSION . + +# Tell python where to find these mysterious precompiled packages +ENV PYTHONPATH=/usr/src/python-packages -CMD ["python3", "src/main.py"] \ No newline at end of file +# And off we go +CMD ["python3", "src/main.py"] diff --git a/VERSION b/VERSION index 60a2d3e..8f0916f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0 \ No newline at end of file +0.5.0 diff --git a/balena.yml b/balena.yml index 193ccdc..8ca55f1 100644 --- a/balena.yml +++ b/balena.yml @@ -69,5 +69,9 @@ data: - screen1Platform: - screen2Platform: - individualStationDepartureTime: False -version: 0.4.0 + - showDepartureNumbers: False + - firstDepartureBold: True + - targetFPS: 70 + - fpsTime: 10 +version: 0.5.0 diff --git a/docs/04-configuration.md b/docs/04-configuration.md index 4ec0b1b..1bb6f01 100644 --- a/docs/04-configuration.md +++ b/docs/04-configuration.md @@ -23,7 +23,11 @@ These environment variables are specified using the [balenaCloud dashboard](http | `screen1Platform` | `1` (sets the platform you want to have displayed on the first or single-screen display) | `screen2Platform` | `2` (sets the platform you want to have displayed on the second display) | `individualStationDepartureTime` | `False` (Displays the estimated or scheduled time of the service at each leg of a journey) - +| `fpsTime` | `4` (adjusts how often the effective FPS is displayed) +| `headless` | `True` (outputs to noop serial device rather than serial port; useful for running on a development machine) +| `showDepartureNumbers` | `True` (adds 1st / 2nd / 3rd as per UK train departures) +| `firstDepartureBold` | `False` (makes the first departure use either the bold or normal font) +| `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%.) 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` diff --git a/requirements.txt b/requirements.txt index 65814e6..5943a03 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ luma.oled timeloop requests -xmltodict \ No newline at end of file +xmltodict diff --git a/src/config.py b/src/config.py index 02dcd86..2915e1a 100644 --- a/src/config.py +++ b/src/config.py @@ -1,18 +1,27 @@ import os import re + def loadConfig(): data = { "journey": {}, "api": {} } + data["targetFPS"] = int(os.getenv("targetFPS") or 70) data["refreshTime"] = int(os.getenv("refreshTime") or 180) + data["fpsTime"] = int(os.getenv("fpsTime") or 180) data["screenRotation"] = int(os.getenv("screenRotation") or 2) data["screenBlankHours"] = os.getenv("screenBlankHours") or "" + data["headless"] = False + if os.getenv("headless") == "True": + data["headless"] = True data["dualScreen"] = False if os.getenv("dualScreen") == "True": data["dualScreen"] = True + data["firstDepartureBold"] = True + if os.getenv("firstDepartureBold") == "False": + data["firstDepartureBold"] = False data["hoursPattern"] = re.compile("^((2[0-3]|[0-1]?[0-9])-(2[0-3]|[0-1]?[0-9]))$") data["journey"]["departureStation"] = os.getenv("departureStation") or "PAD" @@ -26,19 +35,22 @@ def loadConfig(): data["journey"]["individualStationDepartureTime"] = True data["journey"]["outOfHoursName"] = os.getenv("outOfHoursName") or "London Paddington" - data["journey"]["stationAbbr"] = { "International": "Intl." } + data["journey"]["stationAbbr"] = {"International": "Intl."} data["journey"]['timeOffset'] = os.getenv("timeOffset") or "0" - data["journey"]["screen1Platform"] = os.getenv("screen1Platform") - data["journey"]["screen2Platform"] = os.getenv("screen2Platform") + data["journey"]["screen1Platform"] = os.getenv("screen1Platform") or "" + data["journey"]["screen2Platform"] = os.getenv("screen2Platform") or "" - if data["journey"]["screen1Platform"].isnumeric() != True: + if data["journey"]["screen1Platform"].isnumeric() is not True: data["journey"]["screen1Platform"] = "" - if data["journey"]["screen2Platform"].isnumeric() != True: + if data["journey"]["screen2Platform"].isnumeric() is not True: data["journey"]["screen2Platform"] = "" data["api"]["apiKey"] = os.getenv("apiKey") or None data["api"]["operatingHours"] = os.getenv("operatingHours") or "" + data["showDepartureNumbers"] = False + if os.getenv("showDepartureNumbers") == "True": + data["showDepartureNumbers"] = True + return data - \ No newline at end of file diff --git a/src/main.py b/src/main.py index df8687c..3333b53 100644 --- a/src/main.py +++ b/src/main.py @@ -4,18 +4,19 @@ import requests from datetime import datetime -from PIL import ImageFont +from PIL import ImageFont, Image, ImageDraw from trains import loadDeparturesForStation from config import loadConfig from open import isRun -from luma.core.interface.serial import spi +from luma.core.interface.serial import spi, noop from luma.core.render import canvas from luma.oled.device import ssd1322 -from luma.core.virtual import viewport, snapshot, hotspot +from luma.core.virtual import viewport, snapshot from luma.core.sprite_system import framerate_regulator + def makeFont(name, size): font_path = os.path.abspath( os.path.join( @@ -27,19 +28,23 @@ def makeFont(name, size): return ImageFont.truetype(font_path, size, layout_engine=ImageFont.Layout.BASIC) -def renderDestination(departure, font): +def renderDestination(departure, font, pos): departureTime = departure["aimed_departure_time"] destinationName = departure["destination_name"] - def drawText(draw, width, height): - train = f"{departureTime} {destinationName}" - draw.text((0, 0), text=train, font=font, fill="yellow") + def drawText(draw, *_): + if config["showDepartureNumbers"]: + train = f"{pos} {departureTime} {destinationName}" + else: + train = f"{departureTime} {destinationName}" + _, _, bitmap = cachedBitmapText(train, font) + draw.bitmap((0, 0), bitmap, fill="yellow") return drawText def renderServiceStatus(departure): - def drawText(draw, width, height): + def drawText(draw, width, *_): train = "" if departure["expected_departure_time"] == "On time": @@ -50,100 +55,152 @@ def drawText(draw, width, height): train = "Delayed" else: if isinstance(departure["expected_departure_time"], str): - train = 'Exp '+departure["expected_departure_time"] + train = 'Exp ' + departure["expected_departure_time"] if departure["aimed_departure_time"] == departure["expected_departure_time"]: train = "On time" - w = int(font.getlength(train)) - draw.text((width-w,0), text=train, font=font, fill="yellow") + w, _, bitmap = cachedBitmapText(train, font) + draw.bitmap((width - w, 0), bitmap, fill="yellow") return drawText def renderPlatform(departure): - def drawText(draw, width, height): + def drawText(draw, *_): if "platform" in departure: - if (departure["platform"].lower() == "bus"): - draw.text((0, 0), text="BUS", font=font, fill="yellow") - else: - draw.text((0, 0), text="Plat "+departure["platform"], font=font, fill="yellow") + platform = "Plat " + departure["platform"] + if departure["platform"].lower() == "bus": + platform = "BUS" + _, _, bitmap = cachedBitmapText(platform, font) + draw.bitmap((0, 0), bitmap, fill="yellow") return drawText -def renderCallingAt(draw, width, height): +def renderCallingAt(draw, *_): stations = "Calling at: " - draw.text((0, 0), text=stations, font=font, fill="yellow") + _, _, bitmap = cachedBitmapText(stations, font) + draw.bitmap((0, 0), bitmap, fill="yellow") + + +bitmapRenderCache = {} + + +def cachedBitmapText(text, font): + # cache the bitmap representation of the stations string + nameTuple = font.getname() + fontKey = '' + for item in nameTuple: + fontKey = fontKey + item + key = text + fontKey + if key in bitmapRenderCache: + # found in cache; re-use it + pre = bitmapRenderCache[key] + bitmap = pre['bitmap'] + txt_width = pre['txt_width'] + txt_height = pre['txt_height'] + else: + # not cached; create a new image containing the string as a monochrome bitmap + _, _, txt_width, txt_height = font.getbbox(text) + bitmap = Image.new('L', [txt_width, txt_height], color=0) + pre_render_draw = ImageDraw.Draw(bitmap) + pre_render_draw.text((0, 0), text=text, font=font, fill=255) + # save to render cache + bitmapRenderCache[key] = {'bitmap': bitmap, 'txt_width': txt_width, 'txt_height': txt_height} + return txt_width, txt_height, bitmap + + +pixelsLeft = 1 +pixelsUp = 0 +hasElevated = 0 +pauseCount = 0 def renderStations(stations): - def drawText(draw, width, height): - global stationRenderCount, pauseCount + def drawText(draw, *_): + global stationRenderCount, pauseCount, pixelsLeft, pixelsUp, hasElevated - if(len(stations) == stationRenderCount - 5): + if len(stations) == stationRenderCount - 5: stationRenderCount = 0 - draw.text( - (0, 0), text=stations[stationRenderCount:], width=width, font=font, fill="yellow") + txt_width, txt_height, bitmap = cachedBitmapText(stations, font) - if stationRenderCount == 0 and pauseCount < 8: - pauseCount += 1 - stationRenderCount = 0 + if hasElevated: + # slide the bitmap left until it's fully out of view + draw.bitmap((pixelsLeft - 1, 0), bitmap, fill="yellow") + if -pixelsLeft > txt_width and pauseCount < 8: + pauseCount += 1 + pixelsLeft = 0 + hasElevated = 0 + else: + pauseCount = 0 + pixelsLeft = pixelsLeft - 1 else: - pauseCount = 0 - stationRenderCount += 1 + # slide the bitmap up from the bottom of its viewport until it's fully in view + draw.bitmap((0, txt_height - pixelsUp), bitmap, fill="yellow") + if pixelsUp == txt_height: + pauseCount += 1 + if pauseCount > 20: + hasElevated = 1 + pixelsUp = 0 + else: + pixelsUp = pixelsUp + 1 return drawText -def renderTime(draw, width, height): + +def renderTime(draw, width, *_): rawTime = datetime.now().time() hour, minute, second = str(rawTime).split('.')[0].split(':') - w1 = int(fontBoldLarge.getlength("{}:{}".format(hour, minute))) - w2 = int(fontBoldTall.getlength(":00")) + w1, _, HMBitmap = cachedBitmapText("{}:{}".format(hour, minute), fontBoldLarge) + w2, _, _ = cachedBitmapText(':00', fontBoldTall) + _, _, SBitmap = cachedBitmapText(':{}'.format(second), fontBoldTall) - draw.text(((width - w1 - w2) / 2, 0), text="{}:{}".format(hour, minute), - font=fontBoldLarge, fill="yellow") - draw.text((((width - w1 - w2) / 2) + w1, 5), text=":{}".format(second), - font=fontBoldTall, fill="yellow") + draw.bitmap(((width - w1 - w2) / 2, 0), HMBitmap, fill="yellow") + draw.bitmap((((width - w1 - w2) / 2) + w1, 5), SBitmap, fill="yellow") def renderWelcomeTo(xOffset): - def drawText(draw, width, height): + def drawText(draw, *_): text = "Welcome to" draw.text((int(xOffset), 0), text=text, font=fontBold, fill="yellow") return drawText + def renderPoweredBy(xOffset): - def drawText(draw, width, height): + def drawText(draw, *_): text = "Powered by" draw.text((int(xOffset), 0), text=text, font=fontBold, fill="yellow") return drawText + def renderNRE(xOffset): - def drawText(draw, width, height): + def drawText(draw, *_): text = "National Rail Enquiries" draw.text((int(xOffset), 0), text=text, font=fontBold, fill="yellow") return drawText + def renderName(xOffset): - def drawText(draw, width, height): + def drawText(draw, *_): text = "UK Train Departure Display" draw.text((int(xOffset), 0), text=text, font=fontBold, fill="yellow") return drawText + def renderDepartureStation(departureStation, xOffset): - def draw(draw, width, height): + def draw(draw, *_): text = departureStation draw.text((int(xOffset), 0), text=text, font=fontBold, fill="yellow") return draw -def renderDots(draw, width, height): +def renderDots(draw, *_): text = ". . ." draw.text((0, 0), text=text, font=fontBold, fill="yellow") @@ -153,19 +210,19 @@ def loadData(apiConfig, journeyConfig, config): if config['hoursPattern'].match(apiConfig['operatingHours']): runHours = [int(x) for x in apiConfig['operatingHours'].split('-')] - if len(runHours) == 2 and isRun(runHours[0], runHours[1]) == False: + if len(runHours) == 2 and isRun(runHours[0], runHours[1]) is False: return False, False, journeyConfig['outOfHoursName'] - if config['dualScreen'] == True: + if config['dualScreen']: rows = "6" else: rows = "3" - try: + try: departures, stationName = loadDeparturesForStation( journeyConfig, apiConfig["apiKey"], rows) - if (departures == None): + if departures is None: return False, False, stationName firstDepartureDestinations = departures[0]["calling_at_list"] @@ -179,7 +236,7 @@ def loadData(apiConfig, journeyConfig, config): def drawStartup(device, width, height): virtualViewport = viewport(device, width=width, height=height) - with canvas(device) as draw: + with canvas(device): nameSize = int(fontBold.getlength("UK Train Departure Display")) poweredSize = int(fontBold.getlength("Powered by")) NRESize = int(fontBold.getlength("National Rail Enquiries")) @@ -198,6 +255,7 @@ def drawStartup(device, width, height): return virtualViewport + def drawBlankSignage(device, width, height, departureStation): global stationRenderCount, pauseCount @@ -213,7 +271,9 @@ def drawBlankSignage(device, width, height, departureStation): rowTwo = snapshot(width, 10, renderDepartureStation( departureStation, (width - stationSize) / 2), interval=config["refreshTime"]) rowThree = snapshot(width, 10, renderDots, interval=config["refreshTime"]) - rowTime = hotspot(width, 14, renderTime) + # 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) if len(virtualViewport._hotspots) > 0: for vhotspot, xy in virtualViewport._hotspots: @@ -226,7 +286,8 @@ def drawBlankSignage(device, width, height, departureStation): return virtualViewport -def platform_filter(departureData, platformNumber, nextStations, station): + +def platform_filter(departureData, platformNumber, station): platformDepartures = [] for sub in departureData: if platformNumber == "": @@ -236,7 +297,7 @@ def platform_filter(departureData, platformNumber, nextStations, station): res = sub platformDepartures.append(res) - if (len(platformDepartures) > 0): + if len(platformDepartures) > 0: firstDepartureDestinations = platformDepartures[0]["calling_at_list"] platformData = platformDepartures, firstDepartureDestinations, station else: @@ -244,6 +305,7 @@ def platform_filter(departureData, platformNumber, nextStations, station): return platformData + def drawSignage(device, width, height, data): global stationRenderCount, pauseCount @@ -256,7 +318,6 @@ def drawSignage(device, width, height, data): w = int(font.getlength(callingAt)) - callingWidth = w width = virtualViewport.width @@ -264,34 +325,38 @@ def drawSignage(device, width, height, data): w = int(font.getlength(status)) pw = int(font.getlength("Plat 88")) - if(len(departures) == 0): + if len(departures) == 0: noTrains = drawBlankSignage(device, width=width, height=height, departureStation=departureStation) return noTrains + firstFont = font + if config['firstDepartureBold']: + firstFont = fontBold + rowOneA = snapshot( - width - w - pw - 5, 10, renderDestination(departures[0], fontBold), interval=config["refreshTime"]) + width - w - pw - 5, 10, renderDestination(departures[0], firstFont, '1st'), interval=config["refreshTime"]) rowOneB = snapshot(w, 10, renderServiceStatus( departures[0]), interval=10) rowOneC = snapshot(pw, 10, renderPlatform(departures[0]), interval=config["refreshTime"]) rowTwoA = snapshot(callingWidth, 10, renderCallingAt, interval=config["refreshTime"]) rowTwoB = snapshot(width - callingWidth, 10, - renderStations(firstDepartureDestinations), interval=0.1) + renderStations(firstDepartureDestinations), interval=0.02) - if(len(departures) > 1): + if len(departures) > 1: rowThreeA = snapshot(width - w - pw, 10, renderDestination( - departures[1], font), interval=config["refreshTime"]) + departures[1], font, '2nd'), interval=config["refreshTime"]) rowThreeB = snapshot(w, 10, renderServiceStatus( departures[1]), interval=config["refreshTime"]) rowThreeC = snapshot(pw, 10, renderPlatform(departures[1]), interval=config["refreshTime"]) - if(len(departures) > 2): + if len(departures) > 2: rowFourA = snapshot(width - w - pw, 10, renderDestination( - departures[2], font), interval=10) + departures[2], font, '3rd'), interval=10) rowFourB = snapshot(w, 10, renderServiceStatus( departures[2]), interval=10) rowFourC = snapshot(pw, 10, renderPlatform(departures[2]), interval=config["refreshTime"]) - rowTime = hotspot(width, 14, renderTime) + rowTime = snapshot(width, 14, renderTime, interval=0.1) if len(virtualViewport._hotspots) > 0: for vhotspot, xy in virtualViewport._hotspots: @@ -306,12 +371,12 @@ def drawSignage(device, width, height, data): virtualViewport.add_hotspot(rowTwoA, (0, 12)) virtualViewport.add_hotspot(rowTwoB, (callingWidth, 12)) - if(len(departures) > 1): + if len(departures) > 1: virtualViewport.add_hotspot(rowThreeA, (0, 24)) virtualViewport.add_hotspot(rowThreeB, (width - w, 24)) virtualViewport.add_hotspot(rowThreeC, (width - w - pw, 24)) - if(len(departures) > 2): + if len(departures) > 2: virtualViewport.add_hotspot(rowFourA, (0, 36)) virtualViewport.add_hotspot(rowFourB, (width - w, 36)) virtualViewport.add_hotspot(rowFourC, (width - w - pw, 36)) @@ -326,12 +391,15 @@ def drawSignage(device, width, height, data): print('Starting Train Departure Display v' + version_file.read()) config = loadConfig() - - serial = spi(port=0) + if config['headless']: + print('Headless mode, running main loop without serial comms') + serial = noop() + else: + serial = spi(port=0) device = ssd1322(serial, mode="1", rotate=config['screenRotation']) - if config['dualScreen'] == True: - print('Dual screen enabled') - serial1 = spi(port=1,gpio_DC=5, gpio_RST=6) + + if config['dualScreen']: + serial1 = spi(port=1, gpio_DC=5, gpio_RST=6) device1 = ssd1322(serial1, mode="1", rotate=config['screenRotation']) font = makeFont("Dot Matrix Regular.ttf", 10) fontBold = makeFont("Dot Matrix Bold.ttf", 10) @@ -345,57 +413,60 @@ def drawSignage(device, width, height, data): pauseCount = 0 loop_count = 0 - regulator = framerate_regulator(20) + regulator = framerate_regulator(config['targetFPS']) # display NRE attribution while data loads virtual = drawStartup(device, width=widgetWidth, height=widgetHeight) virtual.refresh() - if config['dualScreen'] == True: + if config['dualScreen']: virtual = drawStartup(device1, width=widgetWidth, height=widgetHeight) virtual.refresh() - time.sleep(5) + if config['headless'] is not True: + time.sleep(5) - timeAtStart = time.time()-config["refreshTime"] + timeAtStart = time.time() - config["refreshTime"] timeNow = time.time() - + timeFPS = time.time() + blankHours = [] if config['hoursPattern'].match(config['screenBlankHours']): blankHours = [int(x) for x in config['screenBlankHours'].split('-')] while True: with regulator: - if len(blankHours) == 2 and isRun(blankHours[0], blankHours[1]) == True: + if len(blankHours) == 2 and isRun(blankHours[0], blankHours[1]): device.clear() - if config['dualScreen'] == True: + if config['dualScreen']: device1.clear() time.sleep(10) else: - if(timeNow - timeAtStart >= config["refreshTime"]): - - print('Effective FPS: ' + str(round(regulator.effective_FPS(),2))) + if timeNow - timeFPS >= config['fpsTime']: + timeFPS = time.time() + print('Effective FPS: ' + str(round(regulator.effective_FPS(), 2))) + if timeNow - timeAtStart >= config["refreshTime"]: data = loadData(config["api"], config["journey"], config) - if data[0] == False: + if data[0] is False: virtual = drawBlankSignage( device, width=widgetWidth, height=widgetHeight, departureStation=data[2]) - if config['dualScreen'] == True: + 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"], nextStations, station) - virtual = drawSignage(device, width=widgetWidth,height=widgetHeight, data=screenData) - - if config['dualScreen'] == True: - screen1Data = platform_filter(departureData, config["journey"]["screen2Platform"], nextStations, station) - virtual1 = drawSignage(device1, width=widgetWidth,height=widgetHeight, data=screen1Data) + screenData = platform_filter(departureData, config["journey"]["screen1Platform"], station) + virtual = drawSignage(device, width=widgetWidth, height=widgetHeight, data=screenData) + + if config['dualScreen']: + screen1Data = platform_filter(departureData, config["journey"]["screen2Platform"], station) + virtual1 = drawSignage(device1, width=widgetWidth, height=widgetHeight, data=screen1Data) timeAtStart = time.time() timeNow = time.time() virtual.refresh() - if config['dualScreen'] == True: + if config['dualScreen']: virtual1.refresh() except KeyboardInterrupt: diff --git a/src/open.py b/src/open.py index 1504e20..01dda64 100644 --- a/src/open.py +++ b/src/open.py @@ -1,12 +1,14 @@ from datetime import datetime, time + def is_time_between(begin_time, end_time, check_time=None): # If check time is not given, default to current UTC time check_time = check_time or datetime.now().time() if begin_time < end_time: return check_time >= begin_time and check_time <= end_time - else: # crosses midnight + else: # crosses midnight return check_time >= begin_time or check_time <= end_time + def isRun(start_hour, end_hour): - return is_time_between(time(start_hour,0), time(end_hour,0)) + return is_time_between(time(start_hour, 0), time(end_hour, 0)) diff --git a/src/trains.py b/src/trains.py index 413ff7a..272c6a4 100644 --- a/src/trains.py +++ b/src/trains.py @@ -2,28 +2,36 @@ import re import xmltodict + def removeBrackets(originalName): - return re.split(r" \(",originalName)[0] + return re.split(r" \(", originalName)[0] + def isTime(value): matches = re.findall(r"\d{2}:\d{2}", value) return len(matches) > 0 + def joinwithCommas(listIN): return ", ".join(listIN)[::-1].replace(",", "dna ", 1)[::-1] + def removeEmptyStrings(items): return filter(None, items) + def joinWith(items, joiner: str): filtered_list = removeEmptyStrings(items) return joiner.join(filtered_list) + def joinWithSpaces(*args): return joinWith(args, " ") + def prepareServiceMessage(operator): - return joinWithSpaces("A", operator, "Service") + return joinWithSpaces("A" if operator not in ['Elizabeth Line', 'Avanti West Coast'] else "An", operator, "Service") + def prepareLocationName(location, show_departure_time): location_name = removeBrackets(location['lt7:locationName']) @@ -32,30 +40,37 @@ def prepareLocationName(location, show_departure_time): return location_name else: scheduled_time = location["lt7:st"] - expected_time = location["lt7:et"] + try: + expected_time = location["lt7:et"] + except KeyError: + # as per api docs, it's 'at' if there isn't an 'et': + expected_time = location["lt7:at"] departure_time = expected_time if isTime(expected_time) else scheduled_time formatted_departure = joinWith(["(", departure_time, ")"], "") return joinWithSpaces(location_name, formatted_departure) + def prepareCarriagesMessage(carriages): if carriages == 0: return "" else: return joinWithSpaces("formed of", carriages, "coaches.") + def ArrivalOrder(ServicesIN): ServicesOUT = [] for servicenum, eachService in enumerate(ServicesIN): STDHour = int(eachService['lt4:std'][0:2]) STDMinute = int(eachService['lt4:std'][3:5]) if (STDHour < 2): - STDHour += 24 # this prevents a 12am departure displaying before a 11pm departure - STDinMinutes = STDHour*60 + STDMinute # this service is at this many minutes past midnight + STDHour += 24 # this prevents a 12am departure displaying before a 11pm departure + STDinMinutes = STDHour * 60 + STDMinute # this service is at this many minutes past midnight ServicesOUT.append(eachService) ServicesOUT[servicenum]['sortOrder'] = STDinMinutes ServicesOUT = sorted(ServicesOUT, key=lambda k: k['sortOrder']) return ServicesOUT + def ProcessDepartures(journeyConfig, APIOut): show_individual_departure_time = journeyConfig["individualStationDepartureTime"] APIElements = xmltodict.parse(APIOut) @@ -75,7 +90,7 @@ def ProcessDepartures(journeyConfig, APIOut): BusServices = APIElements['soap:Envelope']['soap:Body']['GetDepBoardWithDetailsResponse']['GetStationBoardResult']['lt7:busServices']['lt7:service'] if isinstance(BusServices, dict): BusServices = [BusServices] - Services = ArrivalOrder(Services + BusServices) # sort the bus and train services into one list in order of scheduled arrival time + Services = ArrivalOrder(Services + BusServices) # sort the bus and train services into one list in order of scheduled arrival time # if there are only bus services from this station elif 'lt7:busServices' in APIElements['soap:Envelope']['soap:Body']['GetDepBoardWithDetailsResponse']['GetStationBoardResult']: @@ -88,13 +103,11 @@ def ProcessDepartures(journeyConfig, APIOut): Services = None return None, departureStationName - # we create a new list of dicts to hold the services Departures = [{}] * len(Services) - for servicenum, eachService in enumerate(Services): - thisDeparture = {} # create empty dict to populate + thisDeparture = {} # create empty dict to populate # next we move elements of dict eachService to dict thisDeparture one by one @@ -119,21 +132,21 @@ def ProcessDepartures(journeyConfig, APIOut): thisDeparture["operator"] = eachService["lt4:operator"] # get name of destination - if not isinstance(eachService['lt5:destination']['lt4:location'],list): # the service only has one destination + if not isinstance(eachService['lt5:destination']['lt4:location'], list): # the service only has one destination thisDeparture["destination_name"] = removeBrackets(eachService['lt5:destination']['lt4:location']['lt4:locationName']) - else: # the service splits and has multiple destinations + else: # the service splits and has multiple destinations DestinationList = [i['lt4:locationName'] for i in eachService['lt5:destination']['lt4:location']] thisDeparture["destination_name"] = " & ".join([removeBrackets(i) for i in DestinationList]) # get via and add to destination name - #if 'lt4:via' in eachService['lt5:destination']['lt4:location']: + # if 'lt4:via' in eachService['lt5:destination']['lt4:location']: # thisDeparture["destination_name"] += " " + eachService['lt5:destination']['lt4:location']['lt4:via'] # get calling points - if 'lt7:subsequentCallingPoints' in eachService: # there are some calling points + if 'lt7:subsequentCallingPoints' in eachService: # there are some calling points # check if it is a list of lists (the train splits, so there are multiple lists of calling points) # or a dict (the train does not split. There is one list of calling points) - if not isinstance(eachService['lt7:subsequentCallingPoints']['lt7:callingPointList'],dict): + if not isinstance(eachService['lt7:subsequentCallingPoints']['lt7:callingPointList'], dict): # there are multiple lists of calling points CallingPointList = eachService['lt7:subsequentCallingPoints']['lt7:callingPointList'] CallLists = [] @@ -143,7 +156,7 @@ def ProcessDepartures(journeyConfig, APIOut): # there is only one calling point in this list CallLists.append([prepareLocationName(eachSection['lt7:callingPoint'], show_individual_departure_time)]) CallListJoined.append(CallLists[sectionNum]) - else: # there are several calling points in this list + else: # there are several calling points in this list CallLists.append([prepareLocationName(i, show_individual_departure_time) for i in eachSection['lt7:callingPoint']]) CallListJoined.append(joinwithCommas(CallLists[sectionNum])) @@ -155,8 +168,8 @@ def ProcessDepartures(journeyConfig, APIOut): prepareCarriagesMessage(thisDeparture["carriages"]) ) - else: # there is one list of calling points - if isinstance(eachService['lt7:subsequentCallingPoints']['lt7:callingPointList']['lt7:callingPoint'],dict): + else: # there is one list of calling points + if isinstance(eachService['lt7:subsequentCallingPoints']['lt7:callingPointList']['lt7:callingPoint'], dict): # there is only one calling point in the list thisDeparture["calling_at_list"] = joinWithSpaces( prepareLocationName(eachService['lt7:subsequentCallingPoints']['lt7:callingPointList']['lt7:callingPoint'], show_individual_departure_time), @@ -165,7 +178,7 @@ def ProcessDepartures(journeyConfig, APIOut): prepareServiceMessage(thisDeparture["operator"]), prepareCarriagesMessage(thisDeparture["carriages"]) ) - else: # there are several calling points in the list + else: # there are several calling points in the list CallList = [prepareLocationName(i, show_individual_departure_time) for i in eachService['lt7:subsequentCallingPoints']['lt7:callingPointList']['lt7:callingPoint']] thisDeparture["calling_at_list"] = joinWithSpaces( joinwithCommas(CallList) + ".", @@ -173,25 +186,26 @@ def ProcessDepartures(journeyConfig, APIOut): prepareServiceMessage(thisDeparture["operator"]), prepareCarriagesMessage(thisDeparture["carriages"]) ) - else: # there are no calling points, so just display the destination + else: # there are no calling points, so just display the destination thisDeparture["calling_at_list"] = joinWithSpaces( thisDeparture["destination_name"], "only.", prepareServiceMessage(thisDeparture["operator"]), prepareCarriagesMessage(thisDeparture["carriages"]) ) - #print("the " + thisDeparture["aimed_departure_time"] + " calls at " + thisDeparture["calling_at_list"]) + # print("the " + thisDeparture["aimed_departure_time"] + " calls at " + thisDeparture["calling_at_list"]) Departures[servicenum] = thisDeparture return Departures, departureStationName + def loadDeparturesForStation(journeyConfig, apiKey, rows): if journeyConfig["departureStation"] == "": raise ValueError( "Please configure the departureStation environment variable") - if apiKey == None: + if apiKey is None: raise ValueError( "Please configure the apiKey environment variable") @@ -212,7 +226,6 @@ def loadDeparturesForStation(journeyConfig, apiKey, rows): """ - headers = {'Content-Type': 'text/xml'} apiURL = "https://lite.realtime.nationalrail.co.uk/OpenLDBWS/ldb11.asmx"