Controlling Philips Hue
Mix.install([
{:hue, "~> 0.2"},
{:kino, "~> 0.19"}
])
What this notebook does
This is the hue library's worked example: it finds your bridge, pairs with it, controls lights by name, and watches state changes stream in live. Run it top to bottom against a real bridge on your network.
The first run pairs — you'll press the bridge's round link button once. The result is saved to your user config directory, so every later run reconnects without asking.
Saved credentials
An application key is a password. This cell keeps it out of the notebook
file itself, in your platform's user config directory under
hue_livebooks/hue.json — so every copy of these notebooks, cloned,
badge-launched, or deployed as a Livebook app, shares one pairing:
- Linux —
~/.config/hue_livebooks/hue.json - macOS —
~/Library/Application Support/hue_livebooks/hue.json - Windows —
%APPDATA%\hue_livebooks\hue.json
Delete that file to start over from discovery.
creds_path =
Path.join(to_string(:filename.basedir(:user_config, "hue_livebooks")), "hue.json")
saved =
case File.read(creds_path) do
{:ok, json} -> Jason.decode!(json)
{:error, :enoent} -> nil
{:error, reason} -> raise "could not read #{creds_path}: #{inspect(reason)}"
end
if saved, do: :reconnecting, else: :first_run
Find the bridge
Hue.Discovery.discover/1 runs mDNS and Philips' cloud endpoint in
parallel, then verifies each candidate with a real connection and pins the
certificate it finds — the SSH host-key model, because a Hue bridge's
certificate can't be verified any conventional way. The pin rides along in
the returned Hue.Bridge.Info and every later connection checks against it.
If discovery finds nothing — mDNS is silent on routed networks, with no
error to explain why — this cell crashes with a MatchError on the empty
list. The escape hatch is Hue.Discovery.identify/2 with the bridge's IP:
it captures the fingerprint on first contact the same way discover/1 does.
On a re-run this cell rebuilds that Info from the saved file instead.
bridge_info =
if saved do
%Hue.Bridge.Info{
host: saved["host"],
port: saved["port"],
bridge_id: saved["bridge_id"],
fingerprint: saved["fingerprint"]
}
else
{:ok, [first | _]} = Hue.Discovery.discover()
first
end
Pair
Press the round link button on your bridge, then run this cell — the bridge only honours pairing requests for about thirty seconds after a press, and this cell polls for up to a minute, so either order works if you're quick.
On a re-run, the saved key is used and nothing is sent to the bridge.
application_key =
if saved do
saved["application_key"]
else
{:ok, %{application_key: key}} =
Hue.Pairing.pair_when_pressed(bridge_info, app: "livebook")
File.mkdir_p!(Path.dirname(creds_path))
File.write!(
creds_path,
Jason.encode!(%{
host: bridge_info.host,
port: bridge_info.port,
bridge_id: bridge_info.bridge_id,
fingerprint: bridge_info.fingerprint,
application_key: key
})
)
File.chmod!(creds_path, 0o600)
key
end
:paired
Start the live bridge
Hue.Bridge never starts itself — it's a child_spec you place in your own
supervision tree, the way Finch or Redix are. In an application that looks
like:
children = [
{Hue.Bridge, name: MyApp.Hue, client: client}
]
In Livebook, Kino.start_child/1 is that same placement. On start the
bridge fetches everything once to seed its cache, then holds an eventstream
open to keep it current; :live means both are done. Kino ties the child's
lifetime to this cell rather than to the runtime, so re-running it tears
down the old bridge and starts a fresh one — no error, no restart required,
the Livebook analogue of a supervisor restarting a child.
{:ok, client} = Hue.from_bridge(bridge_info, application_key: application_key)
{:ok, _supervisor} = Kino.start_child({Hue.Bridge, name: LivebookHue, client: client})
Enum.reduce_while(1..50, nil, fn _, _ ->
case Hue.Bridge.status(LivebookHue) do
:live ->
{:halt, :live}
status ->
Process.sleep(200)
{:cont, status}
end
end)
|> case do
:live -> :live
status -> raise "bridge did not reach :live within 10s, last status: #{inspect(status)}"
end
What's in the house
Reads are :ets.lookup calls in your own process — they never touch the
bridge or even the bridge's process. A light's own metadata is deprecated
in CLIP v2, so names are resolved through the owning device, which
Hue.Bridge.name_of/3 does for you.
{:ok, lights} = Hue.Light.list(LivebookHue)
lights
|> Enum.map(fn light ->
%{
name: Hue.Bridge.name_of(LivebookHue, :light, light["id"]),
on: get_in(light, ["on", "on"]),
brightness: get_in(light, ["dimming", "brightness"])
}
end)
|> Enum.sort_by(& &1.name)
|> Kino.DataTable.new(name: "Lights")
Rooms and scenes carry their own authoritative names:
{:ok, rooms} = Hue.Room.list(LivebookHue)
{:ok, scenes} = Hue.Scene.list(LivebookHue)
Kino.Layout.grid(
[
Kino.DataTable.new(
Enum.map(rooms, &%{room: get_in(&1, ["metadata", "name"])}),
name: "Rooms"
),
Kino.DataTable.new(
Enum.map(scenes, &%{scene: get_in(&1, ["metadata", "name"])}),
name: "Scenes"
)
],
columns: 2
)
Control, by name
Pick one of your lights from the table above and use its name here. Writes
accept :on, :brightness (percent), :color (hex, RGB tuple, or xy),
:kelvin, and :transition (milliseconds). A capability the light doesn't
have — colour on a white-only bulb — returns {:error, %Hue.Error{}}; a
malformed value raises, because that's a bug in the code, not a fact about
the bulb.
light = "Desk Lamp"
:ok = Hue.Light.set(LivebookHue, light, on: true, brightness: 40)
set returns when the write is accepted, not when the light has changed —
writes are coalesced and paced so a burst never floods the bridge. When you
need confirmation, await: true blocks until the confirming event comes
back down the eventstream:
Hue.Light.set(LivebookHue, light, color: "#ff8800", transition: 400, await: true)
Rooms, zones, and scenes work the same way — Hue.Room.set/3,
Hue.Zone.set/3, Hue.Scene.recall/3:
# :ok = Hue.Room.set(LivebookHue, "Living Room", on: false)
# :ok = Hue.Scene.recall(LivebookHue, "Relax")
:skipped
A control panel, elsewhere
Driving lights from forms is a different job from learning the API, so it
has its own notebook: control_panel.livemd is a
full interface to your bridge — rooms, lights, scenes, management — and it
reuses the credentials this notebook just saved, so it connects without
pairing again.
Watch it live
Subscribing delivers {:hue, %Hue.Event{}} to the calling process, and the
subscription dies with that process — so the listener below is a supervised
task that subscribes itself. Flip a light in the Hue app, or re-run the set
calls above, and watch the deltas arrive. Notice a single change fans out:
the bridge also recomputes group aggregates, so one light produces a
grouped_light event too.
frame = Kino.Frame.new(placeholder: false)
{:ok, _listener} =
Kino.start_child(
{Task,
fn ->
:ok = Hue.Bridge.subscribe(LivebookHue)
Stream.repeatedly(fn ->
receive do
{:hue, event} ->
name = Hue.Bridge.name_of(LivebookHue, event.resource_type, event.rid)
Kino.Frame.append(
frame,
Kino.Markdown.new(
"`#{event.resource_type}` **#{name || event.rid}** → `#{inspect(event.data)}`"
)
)
end
end)
|> Stream.run()
end}
)
frame
Where to next
Everything this notebook did, your application does with the same calls:
put {Hue.Bridge, name: MyApp.Hue, client: client} in your supervision
tree, store the application key like the password it is, and read the
moduledocs — Hue.Bridge for the live model and
its failure semantics, Hue.Light for writes, Hue.Resource for the
stateless layer-1 escape hatch this notebook never needed.