Tracks lift (elevator) outages at Irish Rail stations over time, by polling Irish Rail's realtime service-message feed every 30 minutes and recording which messages are present. Built to run unattended on a Raspberry Pi for years, so it can accumulate enough history to look for patterns - which stations break most, how long outages tend to last, whether there's seasonality, and so on.
The collected data is published as a status site at https://baz8080.github.io/lifts - see The site below.
Sibling project: esb, which does the same
thing for ESB Networks power outages. This project mirrors its architecture
closely, adapted for a simpler single-endpoint API.
GET https://connect.irishrail.ie/realtime/messages?lang=en returns a flat
JSON list of every current service-message banner - lift outages, but also
unrelated notices, since the feed isn't lift-specific. Every message seen is
recorded verbatim, regardless of content; filtering "is this actually about a
lift" is left as a query-time concern over the stored data, not a collection-
time decision.
There is no reliable way to ask "is this fixed yet": each message carries an
end field, but in every message observed so far it's set to a placeholder
date near the end of the current calendar year, unrelated to when the actual
issue is likely to be resolved. It's recorded anyway (in case it turns out to
be useful later), but the only signal this project actually trusts for
"fixed" is a message that was present in one run and is absent from the
next.
Since the API has no ID field for a message, identity is derived from head
locationCodes+start. This means an edited message (Irish Rail tweaking the wording, or correcting a start time by a few minutes) will look like the old message closing and a new one opening - a known, accepted limitation. Raw responses are kept forever, so this can be reprocessed with smarter matching later if it turns out to matter.
The design's central concern is making sure a failed poll (network error,
rejected API key, a changed response shape) can never be misread as "the list
came back empty" - which would otherwise mark every currently-open message as
fixed. See lift_status/poll.py for how that's enforced structurally, not
just checked at runtime.
raw/messages-YYYYMMDD.jsonl- one line per poll attempt, success or failure, written before any parsing. This is the actual source of truth.lift_status.db- a derived SQLite database, entirely rebuildable from the raw log viarebuild. Never back this up; back upraw/instead.
python3 -m lift_status poll # run one collection pass (the scheduled command)
python3 -m lift_status check # verify the API key/connectivity; writes nothing
python3 -m lift_status test-alert # send a test alert through LIFT_STATUS_ALERT_WEBHOOK
python3 -m lift_status rebuild # rebuild the database from the raw JSONL logs
python3 -m lift_status stats # summarise what has been collected
All accept --data-dir (default: $LIFT_STATUS_DATA_DIR, or /data).
| Variable | Purpose |
|---|---|
LIFT_STATUS_DATA_DIR |
Storage root (default /var/lib/lift-status once installed) |
LIFT_STATUS_API_KEY |
Required. The x-api-key value, captured from a browser session. Deliberately not stored in this repository |
LIFT_STATUS_ALERT_WEBHOOK |
Where failure alerts are POSTed (an ntfy.sh topic URL works out of the box). An unchanged alert repeats at most daily, so a stuck fault doesn't push every 30 minutes |
LIFT_STATUS_GRACE_MISSES |
Consecutive misses before a message is marked closed (default 1: close on first miss) |
lift_site builds a static status page from the collected data and publishes
it to https://baz8080.github.io/lifts - lift (elevator) and escalator
outages by station, month by month, drilling into each station's full history.
python3 -m lift_site --data-dir /var/lib/lift-status # writes out/site/It reads lift_status.db, so run rebuild first if the database is stale.
The overview lists the stations with a notice in the selected month; a
station's page shows every month since collection began. Everything measured
is the interval a notice was listed for. The start date Irish Rail writes on
a notice is shown as their claim but colours nothing, because it routinely
predates the listing by months over days the feed was watched and the notice
was not there - notices have been seen to appear and disappear in batches,
months after the start date they carry. "No longer listed" is the word used,
never "fixed": there is no completion signal in the feed.
A station is graded on availability: the share of the days watched on which no lift or escalator notice was listed there. The scale is this site's own - the PRM TSI sets design rules and a duty to hold a written access policy, not a number, and Irish Rail publishes no availability target - so the bands are calibrated in days: A is 100% available, B 95%+, C 90%+, D 75%+, F below that. A is not "nothing listed": planned works inside their grace are drawn on the bar and left out of the total. Planned works are excused for their first week and count in full past it, in their own colour once they do. Escalator notices keep their own bar, so a working lift is never painted by a broken escalator, but their days count like a lift's. That makes the grade "something was reported out at this station", not step-free-access availability: a wheelchair user cannot use an escalator, so an escalator going out never removes a step-free route.
A reissued notice that appears at the very poll the old one vanished is one outage with the reissue noted; a notice that comes back a poll or more later is a separate outage, because the gap is what the site is measuring.
notes/site.md has the decisions and the numbers behind them.
.github/workflows/pages.yml rebuilds the database from
lifts-data and publishes the site
daily and on every push to main. It needs Pages enabled once, under
Settings → Pages → source "GitHub Actions". GitHub disables the cron after
60 days without a commit to this repository - the data lands in
lifts-data, which does not count - so re-arm it when the email arrives.
Standard library only, no dependencies to install:
python3 -m unittest discover -s tests -t .
tests/test_site_real.py runs the site pipeline against the real corpus and
skips unless LIFT_STATUS_DATA_DIR points at a data directory with a rebuilt
database; CI sets it. uv run --group dev ruff check lints - uv and ruff are
development tooling only, nothing at runtime needs them.
The most important test is tests/test_rebuild.py: it drives a realistic
run history (opens, updates, a close, a failed run that must change nothing,
a reopen, a duplicate, a schema-drift item) through the real poll code
path, wipes the database, rebuilds it from the raw log alone, and asserts the
result is identical.
git clone git@github.com:baz8080/lifts.git
cd lifts
sudo sh scripts/install-native.sh
This is idempotent - re-run it after a git pull to deploy an update. It
creates a lift-status system user, installs the code to
/opt/lift-status, creates /etc/lift-status.env (chmod 600), and installs
the systemd units. Then, following the printed instructions:
- Set
LIFT_STATUS_API_KEYin/etc/lift-status.env(chmod 600, root-owned). The key is not in this repository: it is Irish Rail's credential, captured from a browser session, and committing it would publish it to everyone who can read the repo. Capture it as described under "Recovering from a rotated API key" below. With no key set, every run fails loudly with a "NO API KEY CONFIGURED" banner rather than quietly collecting nothing. - Set
LIFT_STATUS_ALERT_WEBHOOKin the same file - pick an unguessable ntfy.sh topic name, e.g.https://ntfy.sh/<random-string>, and subscribe to it on your phone. This step is not optional: Irish Rail can rotate the key at any time without notice. Without alerting, collection can stop silently and nobody will know until the gap in the data is noticed much later. sudo lift test-alert- confirm the alert actually reaches your phone.sudo lift check- confirm the current key still works.sudo systemctl start lift-status.servicefor one run now, thensudo systemctl enable --now lift-status.timerfor every 30 minutes.
Raw logs back up to a separate repository (lifts-data), pushed daily via a
dedicated deploy key - mirroring the esb/esb-data pattern:
- Create a new (can be public) GitHub repo, e.g.
lifts-data. - Generate a deploy key with write access:
sudo ssh-keygen -t ed25519 -f /etc/lift-status-deploy-key -N "", then add/etc/lift-status-deploy-key.pubto the repo's Deploy Keys with write access.chownthe private half to the service user -sudo chown lift-status:lift-status /etc/lift-status-deploy-key- sincessh-keygenrun undersudoleaves it root-owned, mode 600, andlift-status-backup.serviceruns as the unprivilegedlift-statususer, not root. cd /var/lib/lift-status && sudo -u lift-status git init -b main && sudo -u lift-status git remote add origin git@github.com:<you>/lifts-data.gitsudo systemctl enable --now lift-status-backup.timer
Separately, point your own NAS backup at /var/lib/lift-status/ for a second
copy - it's a plain directory of a SQLite file and JSONL text files, nothing
NAS-backup-unfriendly about it.
The key lives only in /etc/lift-status.env on the Pi, never in this
repository. If sudo lift check starts failing, or an alert titled "API KEY
REJECTED" arrives: open https://www.irishrail.ie in a browser, open
devtools' Network tab, find the request to
connect.irishrail.ie/realtime/messages, and copy the x-api-key header
value into LIFT_STATUS_API_KEY in /etc/lift-status.env. No data is lost
while this is broken except the gap in coverage itself - the raw log and
database are untouched by an auth failure.
- Identity drift: an edited
heador a correctedstarttime produces a new identity key, which looks like the old message closing and an unrelated new one opening. Raw responses are kept forever in case this needs smarter reprocessing later. - No completion signal from the API:
endis recorded but not trusted; "fixed" is inferred purely from a message's absence in a later run. - Flapping:
LIFT_STATUS_GRACE_MISSESdefaults to1(close on first miss) for simplicity. If the data shows single-cycle blips causing spurious close/reopen pairs, raise it - a message won't be marked closed until it's missed that many consecutive successful runs in a row.