Two steps:
1. Tell DVB who you are. They require every client to identify itself with a name and a
contact address. Put yours in dvb_client_name.txt, next to DVB_Monitor.py:
echo 'DVB Monitor - your name <you@example.com>' > dvb_client_name.txt
That file is gitignored, so your contact details stay out of the repo no matter what you commit. See the dvb package documentation for what they expect.
2. Run it. config.yaml is read from the current directory by default; point somewhere else
with --config myconfig.yaml, or write a fresh one with --generate-config myconfig.yaml.
Because your contact details are not in it, every config.yaml in this repo is safe to commit
and to share.
In order, first hit wins:
dvb_client_name.txtbeside the config file you are usingdvb_client_name.txtbesideDVB_Monitor.py-- the usual case, one file shared by every config in your checkout- a
dvb_client_name:entry in the config file itself
The third is only kept so that setups predating the separate file keep working. Prefer the file; a config with your email in it is a config you cannot share.
Requires the following packages, avail through pip or perhaps mamba/etc:
dvb. Currently using version 3.pyyamlnumpyrequestsandpyproj, both required bydvbitself.pyprojneeds the PROJ binaries, so install it from conda-forge rather than letting pip build it -- especially on a Raspberry Pi.
and also def installed in a mamba env:
pyqt5
In a mamba/micromamba env, that is:
micromamba create -n dvb -c conda-forge python=3.12 pyqt pyyaml numpy requests pyproj
micromamba run -n dvb pip install --no-deps dvb
(the conda-forge package for PyQt5 is called pyqt, not pyqt5.)
The API goes down, rate limits, and occasionally returns something that isn't JSON. None of that stops the display any more:
- a failing stop no longer prevents the other stops from updating
- the last known departures stay on screen, with the stop name marked
⚠️ , and the footer showing when the data was last good - a stop with nothing to show at all is marked ❌ with the reason underneath
- refreshes back off exponentially while everything is failing, and return to the normal interval as soon as anything succeeds
- fetching happens on a background thread, so the window stays responsive even when the API hangs
On a touchscreen with use_backlight_control, the display sleeps once its data has gone stale.
Tapping to wake it also refreshes, so you get current departures rather than an empty table and a
button to press. That first tap only wakes; it does not fall through to whatever was underneath
it.
Relevant config.yaml settings, all optional:
| setting | default | meaning |
|---|---|---|
request_timeout |
8 | seconds to wait for a response body (the dvb package's own default is 15) |
request_connect_timeout |
4 | seconds to wait for the connection |
cache_stop_ids |
true | look up each stop's id once instead of on every request |
retry_backoff_factor |
2.0 | multiplier applied to the interval while every stop is failing |
retry_backoff_max |
600 | seconds; ceiling on that backoff |
retry_when_stale |
false | keep retrying after stale data has been cleared |
fetch_in_background |
true | fetch off the GUI thread; set false to go back to blocking fetches |
stale_data_threshold |
300 | seconds before displayed data is marked stale |
show_stale_data_on_error |
true | keep the last good departures visible when a fetch fails |
error_placeholder |
— |
drawn in a cell whose value could not be computed |
unknown_mode_emoji |
`` | drawn for a mode of transit with no emoji |
The API does not always give a real time for a departure. Those get an infinite number of
minutes, which sorts them to the bottom of the table and shows as inf.
| setting | default | meaning |
|---|---|---|
show_infinite_arrivals |
true | show departures the API gave no time for |
Set it to false to leave them out. They sort last either way, so hiding them never displaces a
departure that does have a time -- it just frees up the rows at the bottom they were occupying.
Two config/stylesheet pairs to copy and cut about, both using every column the app has -- line number, mode emoji, direction arrow, destination and minutes -- and both with more stops than fit on a page, so the prev/next buttons are in play.
python DVB_Monitor.py --config config_showcase.yaml
python DVB_Monitor.py --config config_tutorial.yaml
Add --fake-client ok to either to try it without touching the network.
| pair | for |
|---|---|
config_showcase.yaml + style_showcase.css |
a wide departure-board look, four stops two at a time. Start here if you want something good-looking to adapt. |
config_tutorial.yaml + style_tutorial.css |
the same features with the reasoning left in: seven numbered experiments in the config, four more in the stylesheet, each one a single edit. Start here if you want to understand why. |
The tutorial pair ships with its emoji and arrow columns commented out, so uncommenting them is
one of the experiments. Every experiment in both files has been run -- changing num_stops_per_page
really does make the nav buttons disappear, 18pt really does grow the rows and then warn you that
they no longer fit.
One limitation worth knowing before you spend long on a stylesheet: every departure cell shares a single style class, so you cannot make the minutes bold and the destination plain. Per column, the stylesheet cannot tell them apart. Width, alignment and eliding are per column, in the yaml.
The showcase raises direction_lookups_per_refresh well above the default, so its arrows fill in
on the second refresh rather than trickling in over ten of them. That is right for a board you are
looking at and wrong for one you leave running -- the default is deliberately gentler.
An optional column showing which way each service leaves the stop, as a little arrow:
- header: ""
width: 24
getter: get_direction_arrow
alignment: centerconfig_pitft.yaml has it switched on. Add the column to any other config to get it there too --
it is off unless a column asks for it, and costs nothing at all if you leave it out.
The API does not say which way a departure is heading, so the app works it out: it asks for the stops along the trip, takes the one after yours, and turns the compass bearing between the two into one of eight arrows. Your stop's coordinates arrive with the stop-id lookup the app already does, so they are free.
The trip lookup is not free, so it happens once per route per stop, not once per departure. Every upcoming service of the same line and direction reuses the answer. At Altmarkt that turns fifteen departures into six lookups; at Pirnaischerplatz, into eleven.
| setting | default | meaning |
|---|---|---|
direction_recompute_interval |
3600 | seconds before a cached arrow is worked out again; 0 never recomputes |
direction_lookups_per_refresh |
4 | most lookups any one refresh may make |
Once the arrows are known, a refresh costs exactly what it always did: one API call per stop. The lookups only happen when an arrow is new or has gone stale.
direction_lookups_per_refresh spreads the cold start out rather than firing everything at once.
Three stops with 29 routes between them fill in over about eight refreshes, drawing blank arrows
until each one resolves.
direction_recompute_interval exists because routes do change -- a line can take a different path
once the night schedule starts, without changing its number or its destination. Recomputing every
hour bounds how long an arrow can be wrong. Set it to 0 if you would rather never spend the
calls, and restart the app when the timetable changes.
Arrows are drawn for trams and buses only. Trains run express and stopping services under the same line and destination, so a single cached arrow would be wrong for some of them and there is no cheap way to tell which.
The arrow is the direction the service leaves this stop, which is not always the direction of its destination -- a route that loops or doubles back will point somewhere that looks surprising until you follow it on a map.
Anything the app cannot work out is simply left blank: a terminus, a stop missing from the trip data, or a lookup that failed.
style.css is the default; style_pitft.css is tuned for the 480x320 Adafruit PiTFT. Point at
one with css_file: in your config.
It is Qt's stylesheet language (QSS), which looks like CSS but has no variables, so colours are written out in full each time. Each file opens with the four rules worth changing first -- departure rows, column headings, the stop name, and the timestamp -- and everything after that is chrome.
Rows grow to fit whatever font-size you set, so making the departure text bigger just works;
row_height in your config is treated as a minimum rather than a fixed size. Column widths are
a different matter: they come from the columns: section of your config and cannot grow on their
own, because the window width is fixed. If the text outgrows them, the app says so on startup:
⚠️ column "Dest" is 90px wide but its heading needs 140px at the current font; text will be cut
off. Widen it in your config, or reduce font-size in style.css.
⚠️ the table is 512px tall but the window is only 400px. Fixes: reduce font-size in style.css,
reduce num_rows_per_table (currently 12), or raise window_height.
⚠️ the table is 520px wide but the window is only 480px. Fixes: narrow the columns in your
config, reduce column_group_spacing (currently 20), or raise window_width.
The shipped config*.yaml files and the --generate-config defaults are all sized to fit their
own windows, so a fresh setup starts silently. If you widen a column or raise a font, these
warnings tell you what no longer fits.
Font sizes in the stylesheets are in pt, which Qt converts to pixels using your screen's DPI.
Every size in a config file is in pixels, which it does not. On a 192dpi laptop that means the
text comes out twice the size while the columns stay put -- rows overflow, and destinations get
cut off.
So config pixel sizes are treated as being written for a 96dpi screen, and scaled to whatever you
actually have. On an ordinary display nothing changes. On a 192dpi one, a window_width: 550
window is built 1100px wide, and every column, row and gap grows to match, so the layout keeps
its proportions and its physical size.
| setting | default | meaning |
|---|---|---|
scale_with_screen_dpi |
true | scale config pixel sizes to the screen |
reference_dpi |
96 | the DPI those pixel sizes were written for |
Set scale_with_screen_dpi: false when the numbers are the hardware and must be taken
literally -- config_pitft.yaml does this, because the PiTFT is exactly 480x320 physical pixels.
| setting | default | meaning |
|---|---|---|
button_margin |
8 | pixels of room around the row of buttons along the bottom |
button_spacing |
6 | pixels between one button and the next |
refresh_button_width |
64 | width of the refresh button |
The refresh button holds a single glyph, so it keeps a fixed width and the prev/next buttons -- which carry stop names -- take whatever room is left. With only one stop group to show there are no prev/next buttons at all, and the refresh button stays its own size in the middle rather than stretching the width of the window. It never shrinks below what the glyph needs, whatever you set.
These are config settings rather than stylesheet rules so that they scale with the screen like every other pixel size. In CSS they would be stuck at one size on a high-DPI display while everything around them grew.
Height at the bottom is the scarcest thing on a small screen. config_pitft.yaml uses a 6px
margin and a 12pt departure font, which is what fits six rows, a heading, the timestamp and a row
of touch-sized buttons into 320px. If the contents outgrow the window and it cannot grow -- which
is the case in full screen -- the app says so on startup.
Only these style classes exist; anything else in a stylesheet is ignored:
| class | what it is |
|---|---|
grid_cell |
the departure rows |
grid_header |
the # / Dest / t headings |
haltestelle_header |
the stop name |
haltestelle_header_stale |
stop name, showing old data after a failed fetch |
haltestelle_header_error |
stop name, failed with nothing left to show |
footer |
the timestamp line |
--fake-client {ok,fail,mixed,empty,slow} runs the whole UI against a fake API, with no network
at all, which is the easiest way to see each of the states above:
python DVB_Monitor.py --fake-client mixed
Tests:
QT_QPA_PLATFORM=offscreen pytest tests/
Derived from a version graciously provided by William Nash.