Shelly smart-device client for Elixir — cloud APIs, real-time events, and component-aware status parsing across every hardware generation.
Extracted from NordSwitch, where it drives a live fleet spanning Gen2 (Plus 1PM), Pro (Pro 3) and Gen4 hardware.
The pieces
Shelly.Client— one struct holding an account's server, credentials and pacing key. Everything else takes one.Shelly.OAuth— "connect your Shelly account" authorization-code flow; yields a client whose access token lasts 12 hours.Shelly.Account— token-authorized account API: list all devices (names, models, channels), whole-account status in one call, relay control. The recommended path — no auth keys.Shelly.CloudV2— auth-key API: bulk status (≤10 devices), switch/cover/light control,toggle_afterwatchdog. The key doesn't expire, so this keeps working when a token lapses.Shelly.CloudV1— the deprecated legacy API, kept as a fallback.Shelly.Events— real-time websocket per account with a handler callback (status changes, online transitions).Shelly.Status— one parser for every payload shape: Gen2/3/4 RPC components (switch/cover/light/CCT/RGB(W)/PM1/EM/EM1, flood, smoke, presence, temperature/humidity sensors, voltmeter, devicepower) and Gen1 arrays (relays with Watt-minute counters, lights, rollers, emeters, battery sensors). Reportscomponentandmeteredso a relay without power metering is never mistaken for one drawing 0 W.Shelly.RateGate— per-account request pacing (the cloud 429s beyond ~1 req/s/account); add it to your supervision tree.
Quick start (OAuth path)
# 1. In your supervision tree:
children = [Shelly.RateGate]
# 2. Send the user to Shelly's login:
Shelly.OAuth.authorize_url("https://myapp.example/oauth/callback")
# 3. In your callback — this hands back a ready client:
{:ok, client} = Shelly.OAuth.exchange_code(code)
# Persist client.token, client.expires_at and client.refresh_token,
# and give the client a stable pacing key once you have an id:
client = Shelly.Client.put_rate_key(client, account.id)
# 4. Devices + live data:
{:ok, devices} = Shelly.Account.list_devices(client)
{:ok, statuses} = Shelly.Account.all_statuses(client)
parsed = Shelly.Account.parse_status(statuses["0cdc7ef76644"], 0)
# => %{on: true, watts: 2.4, component: "switch", metered: true, ...}
# 5. Control:
:ok = Shelly.Account.set_switch(client, "0cdc7ef76644", 0, false)
# 6. Realtime:
{:ok, _pid} = Shelly.Events.start_link(client, handler: &handle_event/1)Surviving token expiry
Attach the account's auth key and control keeps working after the 12-hour token dies — only realtime and device discovery need OAuth:
client = Shelly.Client.put_auth_key(client, key)
if Shelly.Client.token_expired?(client) do
Shelly.CloudV2.set_switch(client, id, 0, true)
else
Shelly.Account.set_switch(client, id, 0, true)
endField notes baked into this library
- Cloud rate limit is ~1 request/second/account and it will 429
concurrent bursts — hence
Shelly.RateGate. - Gen1 relay energy counters count Watt-minutes; Gen1 emeters
count real Wh.
Shelly.Statusconverts appropriately. - Websocket events can be partial deltas — guard with
Shelly.Status.has_component?/2(or compareShelly.Status.component_of/2against the device's known component) before parsing. A component can also arrive without its state key, in which case:onisnil, meaning unknown rather than off. - The v1 API is deprecated by Shelly; v2 accepts the same auth key.
- OAuth access tokens last 12 hours (
exp - iat = 43200, measured twice on live accounts). Shelly publishes no refresh grant, butShelly.OAuth.refresh/2works against a token that is still live — a fleet renewing itself every ~11 hours ran unattended for days. Persist:expires_atand renew ahead of it; an expired token cannot be refreshed, only re-authorized. An auth key does not expire at all, which is what covers your app being down across a deadline. - Device ids arrive as hex, as integers, and as decimal strings —
normalize with
Shelly.Events.normalize_device_id/1before matching anything.