Skip to content
integration-technologyPublic

About

Reference app for nest_gen2_sdk: fox, dial, motion wake and temperature on a repurposed Nest Gen2

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

foxbus

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):

  1. Splash — the fox on the blue disc and the room temperature.
  2. To Somewhere — the next 104s at Carousel stop 000000000001.
  3. 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.

Structure

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.

Run foxbus on your own Nest

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 bare deploy_app.sh with no Storage override set still shows the right thing) — edit stops, and (if you want one direction to show by default) the matching Foxbus.Config.default_screen/0 fallback, in mix.exs's env.

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 ../foxbus

See "Deploy" below for what this does, including on a fresh Nest.

Changing the route or stops without a rebuild

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.

Providers

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_id is an ATCO code, lines are 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_id is 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 at https://api.tfl.gov.uk/StopPoint/Search/<name>, then check modes includes "tube" on the result before using it; lines are 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 (see Foxbus.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 TfL app_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 than mix.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.md in nest_gen2_sdk), a TFL_APP_KEY environment 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/:arriving countdown 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_id is the numeric id in a stop's own bustimes.org URL, e.g. 64803547 for https://bustimes.org/stops/64803547; lines are 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_id is "<latitude>,<longitude>,<stopLabel>", not just a stop code — see Foxbus.Adapters.Sources.StagecoachProvider's moduledoc for why this endpoint needs a geocode, not a stop id; lines is 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 — see Foxbus.Adapters.Sources.StagecoachHttp's moduledoc for the full story: the X-SC-apiKey this 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/2 just returns an error like any other failed fetch, no special handling. Started on adc/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 on tis/v3/stop-event-query for the rest of the day plus an early one tomorrow, matching what Stagecoach's own app/site shows — then blended stop-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_KEY environment variable — same pattern, same reasons, as :tfl's TFL_APP_KEY above (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_id is "<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/. lines is accepted but unused — every row's line is 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-encode username:token yourself (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_TOKEN environment variable read first, same pattern as :tfl's TFL_APP_KEY above. See Foxbus.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.

Wi-Fi settings

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.

Power and the battery

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/1 from 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_awake holds (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.

Build and test

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

Deploy

../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

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.

Credits

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.

Licence

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.

About

Reference app for nest_gen2_sdk: fox, dial, motion wake and temperature on a repurposed Nest Gen2

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages