The example app for nest_gen2: a repurposed Nest Learning Thermostat (2nd gen) as a bus-arrival display.
foxbus has its own semantic versioning and is kept as a
GitHub app (not published to Hex) against nest_gen2 on Hex,
with a NEST_GEN2_PATH environment variable override for co-developing both
repos at once — see mix.exs. Every release is a git tag (v0.1.0) with an
entry in CHANGELOG.md.
| foxbus | nest_gen2 |
|---|---|
| 0.3.x | 0.6.x |
| 0.2.x | 0.2.x |
| 0.1.x | 0.1.x |
Turning the dial moves between three screens, clockwise forwards and anticlockwise back, with Nest's own click. By default (see "Changing the route or stops" below for overriding this):
- Splash — the fox on the blue disc and the room temperature.
- To Somewhere — the next 104s at Carousel stop
000000000001. - To Elsewhere — the next 104s at Carousel stop
000000000002.
These are placeholder stops — see "Run foxbus on your own Nest" below for pointing them at real ones.
On a watch-only host-mode display with no dial (such as the public gallery
behind host_viewer_controls: false — see nest_gen2's docs/host.md), an
opt-in config :foxbus, auto_cycle_ms: <ms> steps through the stop screens
on a timer instead, wrapping and skipping the splash and settings. It's off
(nil) by default, so a normal Nest with a dial is unchanged.
A stop screen says "Checking times" until its first stop-closure check has come back — this happens on every boot — rather than risk showing a scheduled, non-arriving bus time before knowing whether the stop is closed.
A bus shows its line and when it's due: a countdown at 10 minutes or less ("4 min"), otherwise the clock time ("at 15:22", or "Tomorrow 06:25" once the board has rolled over to tomorrow's first services). Live buses are white, timetabled ones slightly dimmer. If a stop's board is simply empty, the screen says "No more buses today". If Carousel instead names that stop's exact ATCO code as affected by a notice, the screen turns red with the direction and the notice's explanation — no countdown, since there's no real bus behind it. The screen wakes on the dial or when someone walks up, and sleeps after 30 seconds.
No alarm sounds by default. On a stop's countdown screen (10 minutes or less), pressing the dial arms its next bus, and the bell starts chirping once that bus is 5 minutes or less away — pressing again mutes it. On the plain list (more than 10 minutes out), pressing the dial instead selects a row — the dial then moves a highlight between the listed buses, and pressing again arms (or disarms) that specific one, wherever it is in the list; an armed row keeps a small bell next to it. ~20 s of no interaction returns to normal dial navigation. If a line has an active disruption notice that names this stop specifically, a short line of the notice's own title shows below the countdown; a notice naming other stops on the same line doesn't show here.
Hexagonal, carried over from the busstop app:
| Part | Module |
|---|---|
| Domain | Foxbus.Domain.Arrival — the arrival shape and how its time is shown |
| Ports | Foxbus.Ports.ArrivalsSource, Foxbus.Ports.ArrivalsSink |
| Application service | Foxbus.Arrivals — fetch from the source, publish to the sink |
| Source adapter | Foxbus.Adapters.Sources.CarouselScrapeSource (Carousel's board over HTTPS); StubSource for development |
| Driving adapter | Foxbus.Adapters.Schedulers.AdaptiveBusPoller — 60 s, 30 s near a bus, 30 min when there are none |
| Sink adapter | Foxbus.Adapters.Sinks.ScreensSink → Foxbus.Screens (drawing) and Foxbus.Screens.Layout (pure layout) |
| Settings | Foxbus.Ports.WifiSource, Foxbus.Screens.Settings (state machine) + Foxbus.Screens.SettingsLayout (pure layout) — see below |
UK time comes from Foxbus.LondonTime (the fixed BST rule) instead of tzdata,
and HTTPS is verified against Mozilla's CA set in priv/cacerts.pem (from
curl.se, MPL-2.0), since the Nest has no general certificate store.
Settings (the stops, adapters, dial step) are in mix.exs under env, because on
the Nest the app starts from plain erl, which never reads config/*.exs.
foxbus as cloned shows placeholder stops. To point it at your own:
1. Find your stops' ATCO codes. foxbus only talks to
Carousel Buses' own departure boards
(Foxbus.Adapters.Sources.CarouselScrapeSource and the disruption/closure
sources alongside it all hardcode carouselbuses.co.uk) — it doesn't yet
support other operators or a general journey-planner API. If Carousel
doesn't serve your stop, foxbus won't show anything for it. Each stop is
identified by its ATCO code (the standard UK bus-stop identifier, e.g.
000000000001), which you can look up by name on
bustimes.org — it's in the stop's own URL there.
Confirm Carousel actually publishes live times for it by visiting
https://www.carouselbuses.co.uk/stops/<the-code> in a browser before
configuring foxbus: a working departure board means it'll work; nothing
there means Carousel doesn't serve that stop.
2. Put the codes, names, and route(s) into config. Two ways, same shape:
- At runtime, no rebuild — see "Changing the route or stops without a rebuild" below. This is the easier way to try a stop before committing to it.
- Compiled in, as
mix.exs's own defaults (so a baredeploy_app.shwith no Storage override set still shows the right thing) — editstops, and (if you want one direction to show by default) the matchingFoxbus.Config.default_screen/0fallback, inmix.exs'senv.
Either way, the shape is:
stops: [
%{
key: :towards_somewhere,
provider: :carousel,
stop_id: "<your-stop-1-atco-code>",
title: "To Somewhere",
lines: ["<route-number>"]
},
%{
key: :towards_elsewhere,
provider: :carousel,
stop_id: "<your-stop-2-atco-code>",
title: "To Elsewhere",
lines: ["<route-number>"]
}
]key is just an atom you pick to name each screen internally — it can be
anything, and isn't shown on screen. provider names which data source
this screen uses (:carousel for a Carousel bus stop — see
Foxbus.Ports.Provider.all/0 for what else is available). The ATCO code
and the "To Somewhere" title text are the parts that are actually about
your stop; replace both, along with lines (the route number(s) to watch
at that stop — a list, since a shared stop can see more than one route;
[] means no filtering, show everything; see Foxbus.Config's
moduledoc). One stop works too, with a single map.
If lines watches a route that doesn't always end up at the same place
(a loop service, or a shared stop watching more than one route), the
screen can't show a single title once the listed buses disagree on
where they're going — it shows each bus's own destination instead. Add
an optional stop_name (the physical stop's own name, e.g. "Chalk Hill") to that stop's map and a small fixed line names which stop and
direction this is ("Chalk Hill → Somewhere") above the per-bus
destinations, instead of dropping the title outright.
3. Deploy. From the SDK folder, with foxbus cloned alongside it:
platform/deploy_app.sh ../foxbusSee "Deploy" below for what this does, including on a fresh Nest.
The line, the two stops, and which one shows by default can be overridden at
runtime by Foxbus.Config, which reads them from NestGen2.Storage — set
from your computer, no recompile or redeploy needed. They're only read once,
at start (the pollers are built from them then), so still need the app
restarted (app_watchdog.sh or kill -TERM 1) to take effect:
../nest_gen2_sdk/platform/storage.sh foxbus put stops '[%{key: :towards_somewhere, provider: :carousel, stop_id: "<code>", title: "To Somewhere", lines: ["104"]}, %{key: :towards_elsewhere, provider: :carousel, stop_id: "<code>", title: "To Elsewhere", lines: ["104"]}]'
../nest_gen2_sdk/platform/storage.sh foxbus put default_screen ':towards_somewhere'A key Storage doesn't have falls back to mix.exs' compiled env
(default_screen to :splash); platform/storage.sh foxbus delete <key>
removes an override to go back to that. platform/storage.sh foxbus list
shows what's currently set.
Before nest_gen2 0.3.0 this was a hand-edited config.exs file
(config.exs.example was its template). Foxbus.Config.migrate_from_file/0
carries an existing one's values into Storage once, at the next boot after
upgrading, then leaves the old file alone. A further one-time migration,
Foxbus.Config.migrate_legacy_stops/0, carries an older Storage install's
flat {key, code, title} stops (with one shared lines list) into the
shape above, defaulting every migrated stop to provider: :carousel — in
both cases, nothing needs doing by hand to keep an existing install's route
and stops.
A screen's provider names which Foxbus.Ports.Provider module fetches
its departures (see Foxbus.Ports.Provider.all/0):
-
:carousel— Carousel Buses, HTML-scraped (stop_idis an ATCO code,linesare route numbers like"105") — the default, and the only one that supports stop closures as well as line disruptions. -
:tfl— scheduled TfL Underground departures and live line status, no scraping (stop_idis a TfL StopPoint id for the Underground platform specifically, not a rail station id — they can differ even at a joint station: Amersham's Underground StopPoint is"940GZZLUAMS", not its National Rail id. Look one up athttps://api.tfl.gov.uk/StopPoint/Search/<name>, then checkmodesincludes"tube"on the result before using it;linesare TfL's own line names, e.g."Metropolitan"). Departures come from TfL's own scheduled timetable, not its live Arrivals feed — checked live against a real terminus (Amersham) first: the Arrivals feed only ever lists trains arriving to terminate there, nothing outbound, so it can't answer "when does the next train leave" at a terminus at all (seeFoxbus.Adapters.Sources.TflProvider's moduledoc). The timetable is cached in Storage and only refetched once a UK calendar day, since it's a large (~130 KB) response on a memory-constrained device. No stop-closure concept — TfL has line status, not a per-station closure notice. Needs a free TfLapp_key(register at https://api-portal.tfl.gov.uk; works unauthenticated too, just at a much lower rate limit) — set it, as a secret, via Storage rather thanmix.exs:../nest_gen2_sdk/platform/storage.sh foxbus put tfl_app_key '"<your key>"'Running on a computer instead of the Nest (nest_gen2's host mode — see
docs/host.mdin nest_gen2_sdk), aTFL_APP_KEYenvironment variable is read first and takes priority over Storage — handy for a dotfile that pulls the key from a password manager (export TFL_APP_KEY="$(lpass show --password ...)") without copying it into host Storage by hand. An empty value counts as unset (falls back to Storage), not as "the key is the empty string" — that dotfile line exports""when the password manager is logged out. Nothing sets the variable on the Nest, so it always reads from Storage there.A TfL-provider screen's plain (non-urgent) background is a dark navy (
Foxbus.Screens.Layout.tfl_navy/0, an approximation of TfL's corporate blue) rather than the usual dark grey — not TfL's roundel mark or Johnston typeface, which their own brand standard reserves for licensed use; a plain colour carries no such restriction. The:soon/:arrivingcountdown screens stay orange/green regardless of provider, since that urgency colour matters more than a provider's own look. -
:bustimes— scheduled departures scraped from bustimes.org (stop_idis the numeric id in a stop's own bustimes.org URL, e.g.64803547forhttps://bustimes.org/stops/64803547;linesare route numbers like"34"). Named for the data source, not an operator — bustimes.org is a community aggregator covering whichever operator(s) serve a stop under a given route number (first used for Stagecoach East Scotland's 34/34A at Murthly, but a stop can be served by more than one company under the same number, same as a rider at the stop sees it). Scheduled only, same reasoning as:tfl's timetable — bustimes.org shows no live/real-time indicator on this page. No caching (the departures fragment is small, cheap to refetch every poll), and no disruption or stop-closure concept yet. -
:stagecoach— scheduled departures from Stagecoach's own real-time API (stop_idis"<latitude>,<longitude>,<stopLabel>", not just a stop code — seeFoxbus.Adapters.Sources.StagecoachProvider's moduledoc for why this endpoint needs a geocode, not a stop id;linesis accepted but unused in the request, same as:carousel/:bustimes—Foxbus.Arrivals' own line filtering narrows it down afterwards). For Murthly:"56.52027187440903,-3.466028151335207,64803547"(at Primary School) and"56.52031850489555,-3.465883661467104,64803548"(opp Primary School) — both lat/lon pairs are straight from a real Stagecoach API response, not guessed. Unofficial and unsupported — seeFoxbus.Adapters.Sources.StagecoachHttp's moduledoc for the full story: theX-SC-apiKeythis sends isn't issued to this project, it was found embedded in stagecoachbus.com's own website JavaScript via a browser network capture. No developer docs exist for either endpoint this provider uses as far as we could find. Built on Owain's own explicit decision, accepting that it can stop working at any time without notice — if it does,fetch_arrivals/2just returns an error like any other failed fetch, no special handling. Started onadc/stop-monitor(SIRI Stop Monitoring, genuinely live — an aimed and an expected time per call) but that only ever returns the next call per line; rebuilt ontis/v3/stop-event-queryfor the rest of the day plus an early one tomorrow, matching what Stagecoach's own app/site shows — then blendedstop-monitor's live delay and cancellation data back in for whichever of those scheduled departures it's currently tracking, matched by{line, aimed instant}since the two endpoints use unrelated journey ids. A cancelled bus is kept, not hidden, shown as "Cancelled" in place of a time (Foxbus.Domain.Arrival.eta_text/1) and skipped when deciding whether to show the big countdown screen (Foxbus.Screens.Layout.mode/2), so it can't tick down toward a bus that isn't coming. If the live fetch fails, the scheduled list still shows, just without the overlay for that poll. No caching (both responses are small, cheap to refetch every poll), no disruption or stop-closure concept.The key has no unauthenticated fallback (confirmed live: a wrong key gets a 401), so it must be set, as a secret, via Storage:
../nest_gen2_sdk/platform/storage.sh foxbus put stagecoach_api_key '"<the key>"'or, running on a computer (nest_gen2's host mode), read first from a
STAGECOACH_API_KEYenvironment variable — same pattern, same reasons, as:tfl'sTFL_APP_KEYabove (an empty value counts as unset). -
:chiltern_rail— live Chiltern Railways departures from National Rail's own Darwin feed (OpenLDBWS), the genuine live board (estimated/ actual times, cancellations), not a timetable — a plain REST/JSON API (see realtime.nationalrail.co.uk/LDBWS/docs/documentation.html), not the older SOAP service of the same name.stop_idis"<origin CRS>,<destination CRS>"(National Rail's three-letter station codes), e.g."AMR,MYB"for Amersham towards Marylebone — a destination is needed, not just an origin, so OpenLDBWS can filter to services actually heading that way; look a station's CRS code up at https://www.nationalrail.co.uk/stations/.linesis accepted but unused — every row'slineis the response's own operator code ("CH"). No disruption or stop-closure concept yet. Needs a licensed account's username and issued token, sent as HTTP Basic Auth — pre-encodeusername:tokenyourself (e.g.printf '%s:%s' "$USERNAME" "$TOKEN" | base64) and set the result as a secret via Storage:../nest_gen2_sdk/platform/storage.sh foxbus put chiltern_rail_token '"<base64 username:token>"'or, running on a computer (nest_gen2's host mode), a
CHILTERN_RAIL_TOKENenvironment variable read first, same pattern as:tfl'sTFL_APP_KEYabove. SeeFoxbus.Adapters.Sources.ChilternRailProvider's moduledoc.
Adding more providers (more transport operators, more APIs) means adding a
module implementing Foxbus.Ports.Provider and listing it in all/0 — a
code change and a release, not something loaded onto the device at
runtime; see that module's moduledoc for why.
Pressing the dial on the splash opens settings: Wi-Fi status (SSID, IP, signal), the foxbus and SDK versions, and a flow to join a different network: scan → pick → "Join now" or "Save for later", sharing a character-wheel password entry for secured networks it doesn't already know. "Save for later" stores a network (replacing any saved entry with the same SSID) without switching to it — for somewhere the Nest will go later, such as a phone hotspot that's usually off at home; it only offers networks currently in range, though, same limitation as joining. ~60 s idle, or selecting Back, returns to the splash.
This is built against Foxbus.Ports.WifiSource, a port with two adapters:
NestGen2WifiSource (a thin wrapper over NestGen2.Wifi, nest_gen2 0.2.0 —
save/2 needs 0.3.0 — what mix.exs configures by default) and
FakeWifiSource (deterministic, no real network, for developing and testing
the settings screens without hardware).
The menu's second item, "Screen", sets how the screen sleeps and wakes:
how long idle before it sleeps ("30 s" / "1 min" / "5 min" / "Always on"),
and whether it wakes when someone approaches ("Wake on approach"). Each row
is picked with the dial and cycled in place with a press — no further
screen to drill into. The dial and button always wake the screen; there's
no way to turn that off, since with wake-on-approach also off there'd be no
way back short of a reboot. The choice is saved to NestGen2.Storage (see
Foxbus.ScreenPower) and re-applied on every boot, since NestGen2.Power
itself always starts from its own defaults (30 s, every wake source). It
also decides whether deep sleep is on — see "Power and the battery" below.
On its base or stand the Nest only gets about 120 mA — less than it uses while awake — so without deep sleep the battery actually drains while sitting there "charging". See the SDK's docs/power.md for the budget and what deep sleep does underneath foxbus.
What foxbus does about it:
- Turns deep sleep on via
NestGen2.Power.set_deep_sleep/1from the Screen setting above: on for every real idle timeout (30 s / 1 min / 5 min), off for "Always on", since the screen never sleeps there so there'd be nothing to suspend from (Foxbus.ScreenPower). - Refreshes the shown stop's times as soon as the device wakes
(
{:nest_gen2, :power, :awake}) — a poller's own timer only counts awake time, so without this a stop could otherwise sit several minutes stale until its next scheduled poll. - Releases its
keep_awakeholds (the bus alarm, settings being open) as soon as each is no longer needed, rather than holding the device awake longer than it has to.
What this means day to day:
- "Always on" drains the battery on the stand (the settings UI says so) — use it only on USB power, or only briefly on the stand.
- After a long sleep, the shown times catch up within a second or two of the screen lighting up, not instantly — there's a real fetch in between.
- Arming a bus alarm keeps the Nest fully awake for as long as it's armed, so it won't deep-sleep meanwhile — arm one close to the bus, not as soon as it appears on the board.
Measured on this hardware: without deep sleep, the head battery drained about 70 mV/h and ran flat overnight; with it, about +290 mV/h, holding at its 3.92–3.93 V ceiling for 17+ hours afterwards. The base/stand's own blinking lights (green while starting or updating, red if the battery's too low to start) are explained in the SDK's docs, linked above.
The Nest runs OTP 26 / Elixir 1.17, so build with that toolchain:
. ../nest_gen2_sdk/platform/host_env.sh
mix deps.get
mix test
MIX_ENV=prod mix compile../nest_gen2_sdk/platform/deploy_app.sh . [--no-test] [--sdk]Runs the tests and a prod build, checks foxbus was built against the same
nest_gen2 version installed on the Nest (refusing on a mismatch, unless
--sdk is given — that installs foxbus's version, replacing whatever's
there), uploads foxbus and its other dependencies to the device, and
restarts it. The first deploy to a fresh Nest always installs the SDK too,
--sdk or not. --no-test skips mix test; both flags work in either
order.
assets/screen.png is the 320×320 background: the blue disc (radius 160, black
corners, like the stock UI) with the fox in the centre. tools/make_assets.py
turns it into build/screen.raw; deploy that to
/media/scratch/.nest_gen2_sdk/foxbus/screen.raw. Text is rendered on the device
from the Nest's own Akkurat font by the SDK, so foxbus needs no font file for
that. The bell and warning icons are a separate glyph font, bundled at
priv/MaterialIcons-Regular.ttf — see Licence below.
foxbus runs on Nests rooted with NoLongerEvil-Thermostat by codykociemba, which builds on omap_loader (grant-h / ajb142), the Nest DFU Attack research (exploiteers / GTVHacker) and the FULU right-to-repair bounty. See nest_gen2_sdk for the full credits. Bus times come from Carousel Buses' public departure board.
GPL-3.0-only; see LICENSE. Nest's Akkurat font is licensed separately
and is never part of this repository. priv/cacerts.pem is Mozilla's CA
certificate bundle under MPL-2.0. priv/MaterialIcons-Regular.ttf is Google's
Material Icons font, an unmodified copy of version 1.017 from
google/material-design-icons
(sha256 ef149f08bdd2ff09a4e2c8573476b7b0f3fbb15b623954ade59899e7175bedda),
licensed under Apache-2.0 — see priv/MaterialIcons-LICENSE.txt.