nest_gen2_sdk — public Elixir API (draft)

Copy Markdown View Source

The SDK turns a rooted Nest Learning Thermostat (2nd gen, "Diamond" board) into a small Elixir device: a 320×320 round screen, a rotating ring with a press, a piezo, motion sensors and a climate sensor. Apps depend on the hex package nest_gen2_sdk and use only the functions below. Anything not listed is internal and may change.

Reference app: foxbus (fox logo, dial, temperature; grows into the bus display).

Layers

LayerContentsShips as
PlatformRooting, cross-compiled OTP/Elixir, install folder /media/scratch/.nest_gen2_sdk (leading dot survives Nest's cleanup_scratch), rcS boot hook, stock stack disabled, watchdogSeparate repo + install script
Native helpersevwatch (input events), bplink (backplate UART)Pre-built ARM binaries in priv/
SDKThe modules belowhex package nest_gen2_sdk
Appsfoxbus, othersTheir own Mix projects

Rule: the SDK provides mechanisms with sensible defaults; apps decide policy. The one exception is the backplate keep-alive, which always runs: without it the stand power-cycles the head unit about 36 minutes after it last heard from it.

Starting and events

The SDK is an OTP application; adding the dependency starts its supervision tree (backplate link, display, dial, piezo, power, climate).

NestGen2.subscribe(topic_or_topics) :: :ok
NestGen2.unsubscribe(topic_or_topics) :: :ok

Subscribers receive {:nest_gen2, topic, payload}:

TopicPayloadFrom
:dial%{delta: degrees, angle: degrees}Head unit ring sensor
:dial_step%{direction: :cw | :ccw, angle: degrees} (every step_degrees)Head unit
:light%{level: counts} (light flickering as someone moves nearby, or switched on; a default wake source, since the motion sensors can go quiet for minutes)Backplate light sensor
:button:down | :upHead unit (power chip button)
:motion%{level: 0..10, near: boolean, far: boolean}Backplate PIRs
:climate%{temperature_c: float, humidity_pct: float} (corrected)Backplate
:battery%{millivolts: integer}Backplate
:power:awake | :asleepSDK power policy

Head unit

NestGen2.Display — double-buffered screen

Drawing is batched: calls update the back buffer; present/0 shows the batch in one page flip, so partial frames are never visible.

width() :: 320
height() :: 320
center() :: {160, 160}
radius() :: 160                          # visible circle
safe_radius() :: 145                     # keep text and icons inside this

set_background(image) :: :ok             # 320×320 base layer, used by full redraws
set_background("#1C1C1E") :: :ok        # a colour fills the visible disc, corners black
put_image(x, y, image) :: :ok            # draw a rectangle into the back buffer
fill_rect(x, y, w, h, color) :: :ok
put_text(x, y, text, opts) :: :ok        # opts: font, size, color, align
present() :: :ok                         # flip: show everything drawn since the last present
snapshot() :: image                      # what the screen shows; Image.to_png/1 for a PNG

NestGen2.Image and NestGen2.Text — pixels and fonts

NestGen2.Image.new(width, height, color) :: image
NestGen2.Image.from_raw(width, height, bgrx_binary) :: image
NestGen2.Image.from_png(path_or_binary) :: {:ok, image} | {:error, term}
NestGen2.Image.rgb(r, g, b) :: color

NestGen2.Text.render(text, opts) :: image     # opts: font (:regular | :bold), size, color, background
NestGen2.Text.measure(text, opts) :: {width, height}

Fonts are Nest's own Akkurat, rendered at runtime by the textrender helper (stb_truetype) from the device's /nestlabs/share/fonts; the package never ships font files or anything rendered from them (commercial licence).

NestGen2.Backlight

set(level) :: :ok                        # 0..120; the stock UI uses 113
level() :: integer
set_fade(rise_ms, fall_ms) :: :ok        # LM3530 hardware ramp; stock 260 / 2000

The SDK re-writes the level every second (the panel goes dark ~60 s after the last write), so apps set it once.

NestGen2.Dial — ring and press

angle() :: float                         # degrees since start or last reset
reset_angle() :: :ok
set_step(degrees | nil) :: :ok           # :dial_step events every N degrees (default 10)
set_counts_per_turn(counts) :: :ok       # calibration, default 7800 (ADBS-A350 at 750 cpi)

Holding the press for several seconds triggers the power chip's hardware reset; apps must not ask users for a long press.

NestGen2.Gesture — clicks, double clicks, long presses

Subscribe to :gesture for {:nest_gen2, :gesture, :click | :double_click | :long_press}, worked out from the button's raw :down/:up (which :button still sends at once).

ConfigDefault
button_double_click_ms300a second press within this of the first release is a double click; nil turns double clicks off
button_long_press_ms800held this long is a long press, sent while still held; keep it well under 10 s (holding 10 s restarts the Nest)

A single :click is only certain once the double-click window has passed, so it arrives that long after the release. Apps that need an instant response use :button :down.

NestGen2.Piezo

tone(hz, duration_ms) :: :ok
click() :: :ok                           # Nest's click: 2000 Hz for 3 ms
set_click_on_step(boolean) :: :ok        # click on every :dial_step (default true)

NestGen2.Power — screen wake and sleep

wake() :: :ok
sleep() :: :ok                           # off now, even with holds
awake?() :: boolean
keep_awake(reason \\ nil) :: {:ok, hold} # wake, and no idle sleep until released
release(hold) :: :ok                     # also automatic if the holder exits
holds() :: [%{ref, reason, owner}]
set_idle_timeout(ms | :infinity) :: :ok  # default 30_000
set_wake_sources([:dial | :button | :motion | :light]) :: :ok   # default all four
set_deep_sleep(boolean) :: :ok           # default off (config deep_sleep)

Holds stack: idle sleep resumes once the last one is released, with a fresh idle timeout. Use them for anything that must stay visible, such as an alarm or a settings screen.

Deep sleep. On a stand or base, the Nest gets about 120 mA, less than it uses awake, so an app that never lets it sleep drains the battery even while "plugged in". With deep sleep on, 10 s after the screen goes dark (and with no holds) the whole system suspends to RAM, as Nest's own software does: the BEAM and every process stop and later carry on exactly where they were. The base wakes it for motion (so the screen still lights on approach), the button wakes it, and it wakes by itself at least every 270 s to keep in touch with the base; a wake that doesn't light the screen goes back to sleep after 8 s. Measured on a stand: the battery rose about 290 mV/h with deep sleep, and fell about 70 mV/h without.

While suspended nothing runs and timers count only awake time, so refresh anything time-sensitive on {:nest_gen2, :power, :awake}; Wi-Fi reconnects within a few seconds of waking. A screen kept on (:infinity idle timeout or a hold) never deep-sleeps.

ConfigDefault
deep_sleepfalseturn it on (or set_deep_sleep(true))
deep_sleep_after_ms10_000after the screen sleeps
deep_sleep_max_s270longest sleep before waking to talk to the base
deep_sleep_awake_ms8_000awake time after a wake that doesn't light the screen

Backplate

NestGen2.Motion

level() :: 0..10
set_threshold(level) :: :ok              # level counted as motion, default 2

NestGen2.Climate

read() :: %{temperature_c: float, humidity_pct: float,
            raw_temperature_c: float, board_temperatures_c: [float]}
set_offset(delta_c) :: :ok               # self-heating correction, default -3.7

NestGen2.Battery

millivolts() :: integer | nil

NestGen2.Backplate — advanced, unstable

For decoding messages the SDK does not yet cover. May change between minor versions.

status() :: %{connected: boolean, frames_received: integer, last_frame_at: DateTime.t()}
subscribe_raw() :: :ok                   # {:nest_gen2_backplate, cmd, payload}
send_raw(cmd, payload) :: :ok

Wi-Fi

NestGen2.Wifi manages the connection through wpa_supplicant, which the platform runs in place of Nest's connection manager (platform/wifi). It's :unavailable if that isn't running.

NestGen2.Wifi.status() :: %{state: :connected | :connecting | :disconnected | :unavailable,
                            ssid: String.t() | nil, ip: String.t() | nil, signal_dbm: integer | nil}
NestGen2.Wifi.scan() :: {:ok, [%{ssid, signal_dbm, secured, saved}]} | {:error, term}   # ~3-8 s
NestGen2.Wifi.connect(ssid, password | nil) :: {:ok, %{ssid, ip}}
    | {:error, :wrong_password | :not_found | :no_ip | :timeout | :password_required
              | :bad_password_length | :busy | :unavailable | term}                   # up to ~1 min
NestGen2.Wifi.save(ssid, password | nil) :: :ok | {:error, :bad_password_length | :unavailable | term}
NestGen2.Wifi.saved_networks() :: [String.t()]
NestGen2.Wifi.forget(ssid) :: :ok | {:error, :not_found | :connected | :unavailable}
# event: {:nest_gen2, :wifi, %{state, ssid, ip}} on any change

connect/2 only saves a network once it has an address; on any failure it goes back to the previous network and leaves the saved configuration alone. Passwords are stored as the derived WPA key. Call it from a Task, as it blocks. save/2 stores a network without switching to it (replacing one with the same SSID), for setting up somewhere the Nest will go later, such as a phone hotspot; the Nest joins it by itself when it's in range. The platform's wifi.sh guard also restarts wpa_supplicant from the saved configuration if the gateway is unreachable for 3 minutes.

Storage

NestGen2.Storage keeps an app's settings in flash, safe against the power going mid-write, so an app can save its configuration and read it back at start-up.

NestGen2.Storage.put(key, value) :: :ok | {:error, :not_storable | posix}
NestGen2.Storage.get(key, default \\ nil) :: value | default
NestGen2.Storage.delete(key) :: :ok
NestGen2.Storage.keys() :: [String.t()]
NestGen2.Storage.dir() :: path        # the app's own folder, for anything else
# every function takes app: :name when not called from inside the app
  • Each app has its own folder, /media/scratch/.nest_gen2_sdk/data/<app>; each key is one file there with the value as readable text and a checksum.
  • Writes go to a temporary file, flushed, then renamed into place, keeping the previous value as a backup. A damaged value falls back to the backup, then to the default; get/2 never raises.
  • Keys: lowercase letters, digits and _. Values: anything that can be written as text and read back (maps, lists, tuples, strings, numbers, atoms; not pids or functions).
  • For settings that change now and then, not for data written every second: the flash is slow and wears.
  • From your computer: platform/storage.sh <app> list | get <key> | put <key> '<term>' | delete <key>.

Version

NestGen2.version() :: String.t()   # the SDK's version, e.g. "0.2.0"

Clock

NestGen2.Clock keeps UTC right: SNTP shortly after start and then hourly, stepping the system clock when it is 0.5 s or more out, and saving it to the RTC after every check. Local time and daylight saving are the app's job.

NestGen2.Clock.sync() :: {:ok, %{server: term, offset_ms: integer}} | {:error, term}
NestGen2.Clock.status() :: %{synced_at: DateTime.t() | nil, server: term, offset_ms: integer | nil, last_error: term}
NestGen2.Clock.observe(utc :: DateTime.t(), source) :: :ok   # fallback, e.g. an HTTPS Date header

Configuration

config :nest_gen2,
  install_dir: "/media/scratch/.nest_gen2_sdk",
  idle_timeout_ms: 30_000,
  wake_sources: [:dial, :button, :motion, :light],
  motion_threshold: 2,
  light_wake_flicker_pct: 6,    # :light = a fall and a rise of this % within 5 s (not clouds) ...
  light_wake_jump_pct: 40,      # ... or one jump this big (a light switched on)
  light_wake_min_delta: 200,    # smaller changes (raw counts) don't count
  dial_counts_per_turn: 7800,
  dial_step_degrees: 10,
  click_on_step: true,
  temperature_offset_c: -3.7,
  text_render_ms: 20_000,       # a render taking longer returns {:error, :timeout}
  time_servers: ["time.nest.com", "pool.ntp.org", :gateway],
  clock_sync_interval_ms: 3_600_000,
  clock_step_ms: 500

Host mode

config :nest_gen2, target: :host runs an app on a computer: no hardware, the screen drawn in memory and shown in a browser at http://localhost:4000, the ring turned with the mouse. See host.md.

NestGen2.Host.turn(degrees) :: :ok        # clockwise if positive
NestGen2.Host.step(:cw | :ccw) :: :ok     # one Dial step
NestGen2.Host.click() / double_click() / long_press() :: :ok
NestGen2.Host.motion(level \\ 5) :: :ok
NestGen2.Host.climate(temperature_c, humidity_pct) :: :ok
NestGen2.Host.screenshot() :: png_binary
NestGen2.Dial.turn(degrees) / press(:down | :up) :: :ok   # the same, on the Nest too

Not in the API

Framebuffer page tracking and damage regions, the pan/virtual-size sysfs writes, the backplate keep-alive exchange (0x83, 0xa2, 0xa3 every 30 s), frame CRCs, evdev record parsing and the native helper protocols are internal.

foxbus against this API (sketch)

NestGen2.Dial.set_step(45)
NestGen2.subscribe([:dial_step, :button, :climate])
# splash:  set_background(fox_image); put_text(160, 240, "#{t}°C", align: :center); present()
# stops:   set_background("#1C1C1E"); put_text(...) per bus; present()
# on {:nest_gen2, :dial_step, %{direction: d}}: move to the next/previous screen and redraw