Developers

Build apps for Lutella homes

Apps use the gateway's client API v2, at home or away, with the same code. A member authorizes your app for one home and a set of scopes; your app gets a short-lived gateway token and calls the gateway on the local network or through the relay. An app never gets more than the member who authorized it, nor more than the scopes they consented to.

The flow

  1. Register. An Owner registers the app on My homes: a name, exact redirect URIs, and public (no secret: browser and mobile apps) or confidential (a secret, shown once). The app can then be authorized by members of that home.
  2. Authorize. Send the member's browser to the authorization endpoint with an S256 PKCE challenge (required; plain is refused). They sign in, pick the home and see what your app asks for. The code that comes back to your redirect URI is single-use and lives 1m0s.
  3. Token. Exchange the code at the token endpoint. You get a gateway token (access_token, 10m0s) and a refresh_token. Its permissions are the consented scopes the member still holds right now.
  4. Refresh. Each refresh returns a new gateway token and a new refresh token; the old one stops working. Presenting a used refresh token revokes the whole authorization.
  5. Revoke. Post the refresh token to the revocation endpoint (RFC 7009). The member can also disconnect your app from their account page, and an Owner from the home.

Discovery: https://lutella.cloud/.well-known/oauth-authorization-server. Gateway tokens are ES256 JWTs from the issuer https://lutella.eecapital.co, signed with the keys at https://lutella.cloud/.well-known/jwks.json; their azp claim is your client id.

Scopes

Ask for exactly what you need, space-separated. Each scope is one permission; there are no wildcards, and management permissions are never available to apps.

ScopeGroupLets the app
readSeeSee rooms, devices and their live state
control.lightsControlTurn lights on and off, dim them and change their colour
control.shadesControlOpen, close and position shades and blinds
control.climateControlChange air conditioning modes, fan speeds and temperatures
control.mediaControlChange volume, sources and playback on music and TV players
control.switchesControlTurn on/off switches such as fans, towel rails and outlets
control.scenesControlRecall scenes and hand rooms to their schedules
doorsDoorsOpen doors and gates, on site or remotely from the console where the release is tracked; app opens remotely need an Owner's per-door allowance
doors.adminDoorsDisable and enable door access in the key's rooms
voiceVoiceUse the voice assistant
calibrateSetupSurvey indoor positioning with this device

Reaching the gateway: LAN or relay

The token response names the gateway (gateway_id, property_id) and how to reach it:

The client API v2 contract is served by the gateway at …/api/v2/properties/{property_id}/openapi.json (send the same bearer token).

Example: curl

VERIFIER=$(openssl rand -base64 48 | tr -d '=+/\n' | cut -c1-64)
CHALLENGE=$(printf %s "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 | tr '+/' '-_' | tr -d '=')

# 1. Open in the member's browser:
echo "https://lutella.cloud/oauth/authorize?response_type=code&client_id=app_example&redirect_uri=http%3A%2F%2F127.0.0.1%3A5310%2Fcb&scope=read%20control.lights&state=xyz&code_challenge=$CHALLENGE&code_challenge_method=S256"

# 2. Exchange the code from the redirect:
curl -s https://lutella.cloud/oauth/token -d grant_type=authorization_code -d client_id=app_example \
  -d redirect_uri=http://127.0.0.1:5310/cb -d code="$CODE" -d code_verifier="$VERIFIER"

# 3. Read the home, then turn a light on (through the relay):
BASE=https://relay.lutella.eecapital.co/g/$GATEWAY_ID/api/v2/properties/$PROPERTY_ID
curl -s -H "Authorization: Bearer $ACCESS_TOKEN" $BASE/snapshot
curl -s -X POST -H "Authorization: Bearer $ACCESS_TOKEN" -H "Idempotency-Key: $(uuidgen)" \
  -H 'Content-Type: application/json' -d '{"resource_id":"light:example-light","action":"turn_on"}' $BASE/commands

# 4. Later: refresh (rotates the refresh token), or revoke.
curl -s https://lutella.cloud/oauth/token -d grant_type=refresh_token -d client_id=app_example -d refresh_token="$REFRESH_TOKEN"
curl -s https://lutella.cloud/oauth/revoke -d client_id=app_example -d token="$REFRESH_TOKEN"

Example: JavaScript

const cloud = "https://lutella.cloud", clientId = "app_example", redirectUri = location.origin + "/cb";
const b64url = (b) => btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");

async function signIn() {
  const verifier = b64url(crypto.getRandomValues(new Uint8Array(32)));
  const challenge = b64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
  sessionStorage.verifier = verifier;
  location = cloud + "/oauth/authorize?" + new URLSearchParams({ response_type: "code", client_id: clientId,
    redirect_uri: redirectUri, scope: "read control.lights", code_challenge: challenge, code_challenge_method: "S256" });
}

async function onRedirect() {
  const code = new URLSearchParams(location.search).get("code");
  const t = await (await fetch(cloud + "/oauth/token", { method: "POST", body: new URLSearchParams({
    grant_type: "authorization_code", client_id: clientId, redirect_uri: redirectUri, code, code_verifier: sessionStorage.verifier }) })).json();
  const base = `${t.relay_url}/g/${t.gateway_id}/api/v2/properties/${t.property_id}`;
  return (await fetch(base + "/snapshot", { headers: { Authorization: "Bearer " + t.access_token } })).json();
}

Browser apps calling the token endpoint directly need their origin allowed by the Lutella Cloud operator.