Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
aac6a57
Added Nexus as a provider
Woll78 Nov 20, 2021
4b74822
Tweaks
Woll78 Nov 22, 2021
650f66c
Tweaks to layout
Woll78 Nov 22, 2021
582aa11
Empty array check for NS
Woll78 Nov 22, 2021
86eccd4
Stop seardh
Woll78 Nov 23, 2021
2b4c3dc
Fix Go North East time parsing in nexus BusData
Oct 12, 2026
733242a
Keep Stagecoach journey fields together and drop cancelled buses
Oct 12, 2026
95c45dc
Fix TfL next_trains without direction and unknown-direction filter
Oct 12, 2026
24c8c89
Catch public grpc.RpcError in provider view models
Oct 12, 2026
863eb17
Keep data updater thread alive after a failed poll
Oct 12, 2026
3bd18f7
Parse National Rail services individually and drop debug scan
Oct 12, 2026
d5a3d3d
Handle empty input and missing glyphs in BoardText
Oct 12, 2026
4cf4247
Avoid shared mutable defaults in Board and BoardSection
Oct 12, 2026
86bc1d2
Regenerate protobuf code and raise dependency floors
Oct 12, 2026
64f0e4d
Merge branch 'master' into code-review-fixes
Woll78 Oct 12, 2026
29eca53
Require token to shut down the web server host
Oct 12, 2026
fb7866e
Show "Information indisponible" when SNCF departures cannot be fetched
Oct 12, 2026
574f978
Test SNCF outage handling from the HTTP layer
Oct 12, 2026
8c0e292
Show "Information indisponible" when Transilien departures cannot be …
Oct 12, 2026
2ec36b9
Show "Information indisponible" when RATP departures cannot be fetched
Oct 12, 2026
80259ef
Show "Data unavailable" when NS departures cannot be fetched
Oct 12, 2026
6ae0911
Show "Data unavailable" when National Rail departures cannot be fetched
Oct 12, 2026
7f8fc79
Show "Data unavailable" when TfL Tube trains cannot be fetched
Oct 12, 2026
a0dc5c6
Show "Data unavailable" when Nexus bus departures cannot be fetched
Oct 12, 2026
f593ce8
Make board client start/stop safe when switching stations
Oct 12, 2026
ccf3138
Stop CLI board commands starting with an invalid station
Oct 12, 2026
650fe9e
Package Nexus stop data so the installed CLI starts
Oct 12, 2026
6e39ec6
Replace stale requirements.txt with requirements-dev.txt
Oct 12, 2026
78981cf
Run tests in CI and before publishing
Oct 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions .github/workflows/python-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,28 @@ on:
workflow_dispatch:

jobs:
test:
uses: ./.github/workflows/tests.yml

deploy:
needs: test

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v2
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install setuptools wheel twine
pip install build twine
- name: Build and publish
env:
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
run: |
python setup.py sdist bdist_wheel
python -m build
twine upload dist/*
37 changes: 37 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Tests

on:
push:
branches: [master]
pull_request:
workflow_call:

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
# oldest supported (setup.py python_requires) and newest
python-version: ["3.10", "3.14"]

steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: python -m pip install -r requirements-dev.txt

- name: Run tests
run: python -m pytest -q

# catches data files missing from the package, which only fail once
# installed, e.g. `departure --help` crashing on import
- name: Check built package installs and runs
run: |
python -m build --wheel --outdir dist
python -m venv /tmp/wheel-venv
/tmp/wheel-venv/bin/pip install dist/*.whl
cd /tmp && /tmp/wheel-venv/bin/departure --help
14 changes: 14 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Python: Module",
"type": "python",
"request": "launch",
"module": "departure.cli.client",
}
]
}
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
include departure/provider/national_rail/data/station_codes.csv
include departure/provider/nexus/data/stops.csv
include departure/provider/ns/data/stations-2020-01-nl.csv
include departure/provider/ratp/data/Wsiv.wsdl
include departure/provider/sncf/data/referentiel-gares-voyageurs.json
Expand Down
18 changes: 12 additions & 6 deletions departure/board/board.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from typing import Tuple, List
from typing import Tuple, List, Optional

import departure.board.movement as movement
from departure.board.movement import NoMovement

Pixel = Tuple[int, int, Tuple[int, int, int]]
Pixels = List[Pixel]
Expand Down Expand Up @@ -102,7 +103,7 @@ def __init__(
output_w: int,
output_h: int,
content: BoardSectionContent,
movement: movement.Movement = movement.NoMovement(),
movement: Optional[movement.Movement] = None,
):
# area on the departure board where this section is displayed
self.output_x = output_x
Expand All @@ -113,8 +114,8 @@ def __init__(
# content to be displayed in this section
self._content = content

# movement class
self._movement = movement
# movement class (fresh instance per section: movements hold state)
self._movement = movement if movement is not None else NoMovement()

# pixels in absolute coordinates
self.section_pixels = None
Expand Down Expand Up @@ -216,10 +217,15 @@ def pixels(self) -> Pixels:


class Board:
def __init__(self, board_w: int, board_h: int, sections: List[BoardSection] = []):
def __init__(
self,
board_w: int,
board_h: int,
sections: Optional[List[BoardSection]] = None,
):
self.board_w = board_w
self.board_h = board_h
self.sections = sections
self.sections = sections if sections is not None else []
self.display_pixels = None
self._has_changed = True

Expand Down
135 changes: 96 additions & 39 deletions departure/board/board_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,53 +11,110 @@
logger = logging.getLogger(__name__)


class BoardClient: # pylint: disable=too-few-public-methods
def __init__(self):
self.running = False
class BoardGate:
"""
Forwards calls to a view model until closed, then drops them.

def run(
self,
view_model_class: view_model.ViewModel,
data_updater_class: data_updater.DataUpdater,
data_updater_extra_arguments: dict,
) -> None:
A stopped data updater may still be in the middle of a poll (HTTP or SOAP
call); closing the gate stops that poll from reaching the board once a
new client has taken over.
"""

end_event = threading.Event()
def __init__(self, target_view_model: view_model.ViewModel):
self._view_model = target_view_model
self._lock = threading.Lock()
self._open = True

# set departure board server
if "DEPARTURE_BOARD_SERVER" in os.environ:
departure_board_server = os.environ["DEPARTURE_BOARD_SERVER"]
server_info_str = "DEPARTURE_BOARD_SERVER env var set - "
else:
server_info_str = "DEPARTURE_BOARD_SERVER env var not set - "
departure_board_server = "127.0.0.1"
logger.info("%s using %s as server", server_info_str, departure_board_server)
def __getattr__(self, name):
method = getattr(self._view_model, name)

# open channel to board server
channel = grpc.insecure_channel(f"{departure_board_server}:50051")
def forward(*args, **kwargs):
with self._lock:
if self._open:
return method(*args, **kwargs)
return None

# set up client parameters
board_manager_stub = departure_pb2_grpc.BoardManagerStub(channel)
target_view_model = view_model_class(board_manager_stub)
return forward

# init and run updater thread
data_updater_thread = data_updater_class(
target_view_model=target_view_model,
data_refresh_delay_in_s=15,
end_event=end_event,
**data_updater_extra_arguments,
)
def close(self) -> None:
# waits for any call in progress, so nothing reaches the board after
with self._lock:
self._open = False

data_updater_thread.start()

self.running = True
class BoardClient:
def __init__(self):
self._lock = threading.Lock()
self._channel = None
self._view_model = None
self._gate = None
self._end_event = None

@property
def running(self) -> bool:
return self._gate is not None

# run until self.running is set to false
while self.running:
end_event.wait(0.5)
def start(
self,
view_model_class: view_model.ViewModel,
data_updater_class: data_updater.DataUpdater,
data_updater_extra_arguments: dict,
) -> None:
with self._lock:
# replace any client already running
self._stop()

# set departure board server
if "DEPARTURE_BOARD_SERVER" in os.environ:
departure_board_server = os.environ["DEPARTURE_BOARD_SERVER"]
server_info_str = "DEPARTURE_BOARD_SERVER env var set - "
else:
server_info_str = "DEPARTURE_BOARD_SERVER env var not set - "
departure_board_server = "127.0.0.1"
logger.info(
"%s using %s as server", server_info_str, departure_board_server
)

# open channel to board server
self._channel = grpc.insecure_channel(f"{departure_board_server}:50051")

# set up client parameters
board_manager_stub = departure_pb2_grpc.BoardManagerStub(self._channel)
self._view_model = view_model_class(board_manager_stub)
self._gate = BoardGate(self._view_model)
self._end_event = threading.Event()

# init and run updater thread
data_updater_thread = data_updater_class(
target_view_model=self._gate,
data_refresh_delay_in_s=15,
end_event=self._end_event,
**data_updater_extra_arguments,
)
# a poll in flight must not keep the process alive on exit
data_updater_thread.daemon = True
data_updater_thread.start()

def stop(self) -> None:
with self._lock:
self._stop()

def _stop(self) -> None:
if self._gate is None:
return

# clean and exit
logger.info("stopping")
end_event.set()
data_updater_thread.join()
target_view_model.update(None)

# the updater thread ends after its current poll, if any; the closed
# gate stops that poll reaching the board, so there's no need to wait
self._end_event.set()
self._gate.close()

# clear board
self._view_model.update(None)
self._channel.close()

self._channel = None
self._view_model = None
self._gate = None
self._end_event = None
24 changes: 20 additions & 4 deletions departure/board/contents.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,28 @@ def _glyph_pixels(self, glyph, x_offset, y_offset, colour=(255, 255, 255)):
if glyph.data[y] & (1 << (glyph.bbW - x - 1))
]

def _glyph(self, char):
# fonts only cover some characters (e.g. no accents or CJK): fall back
# to "?" so one odd station name doesn't break the whole board
for candidate in (char, "?"):
try:
return self.font[ord(candidate)]
except KeyError:
pass
return None

def _line_height(self):
return self.font[b"FONT_ASCENT"] + self.font[b"FONT_DESCENT"]

def monochrome_text_pixels(
self, text, colour=(255, 255, 255), max_width=0 # 0 == no max width
):
pixels = []
advance = 0
for c in text:
glyph = self.font[ord(c)]
glyph = self._glyph(c)
if glyph is None:
continue
pixels += self._glyph_pixels(
glyph, advance, self.font[b"FONT_ASCENT"] - 1, colour
)
Expand All @@ -51,7 +66,7 @@ def colour_text_pixels(self, text_array):
if self.colour_map is None:
raise BoardTextException("missing colour map")

x_size, y_size = 0, 0
x_size, y_size = 0, self._line_height()
output_pixels = []

for text_part in text_array:
Expand All @@ -65,7 +80,7 @@ def colour_text_pixels(self, text_array):
)
output_pixels += [(x + x_size, y, c) for x, y, c in pixels]
x_size += size[0]
y_size = size[1]
y_size = size[1]
return output_pixels, (x_size, y_size)

def colour_text_tabbed_row_pixels(self, text_row):
Expand All @@ -74,6 +89,7 @@ def colour_text_tabbed_row_pixels(self, text_row):

output_pixels = []
x_size = 0
y_size = self._line_height()
for i, text_part in enumerate(text_row):
tab_pos, tab_type = self.tabs[i]
text, colour = text_part[:2]
Expand All @@ -97,7 +113,7 @@ def colour_text_tabbed_row_pixels(self, text_row):
# extend width as needed
x_size = max(x_size, tab_pos + 1)

y_size = size[1]
y_size = size[1]

return output_pixels, (x_size, y_size)

Expand Down
15 changes: 14 additions & 1 deletion departure/board/data_updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,15 @@ def run(self):
loop_start_time = time.monotonic()
next_loop_start_time = loop_start_time + self.data_refresh_delay_in_s

self.update()
try:
self.update()
except Exception: # pylint: disable=broad-except
# one bad poll must not end the thread and freeze the board
logger.exception("data update failed, will retry")
try:
self.update_failed()
except Exception: # pylint: disable=broad-except
logger.exception("could not show data update failure")

if next_loop_start_time > time.monotonic():
self.end_event.wait(next_loop_start_time - time.monotonic())
Expand All @@ -35,3 +43,8 @@ def run(self):

def update(self):
raise NotImplementedError()

def update_failed(self):
# called after update() raises; override to stop the board showing
# data from before the failure as if it were live
pass
Loading