Hue Control Panel
Mix.install([
{:hue, "~> 0.2"},
{:kino, "~> 0.19"}
])
Setup & Connect
Deploy as a Livebook app (the app icon in the sidebar) to run everything
and see only the panel. In the editor, press e a to run all — every cell
is required.
Credentials load from your platform's user config directory, shared with the walkthrough (its "Saved credentials" section lists the per-platform paths). First run discovers the bridge and pairs — press the round link button when asked.
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
{bridge_info, application_key} =
if saved do
{%Hue.Bridge.Info{
host: saved["host"],
port: saved["port"],
bridge_id: saved["bridge_id"],
fingerprint: saved["fingerprint"]
}, saved["application_key"]}
else
{:ok, [info | _]} = Hue.Discovery.discover()
IO.puts("Press the round link button on the bridge…")
{:ok, %{application_key: key}} = Hue.Pairing.pair_when_pressed(info, app: "livebook")
File.mkdir_p!(Path.dirname(creds_path))
File.write!(
creds_path,
Jason.encode!(%{
host: info.host,
port: info.port,
bridge_id: info.bridge_id,
fingerprint: info.fingerprint,
application_key: key
})
)
File.chmod!(creds_path, 0o600)
{info, key}
end
{:ok, client} = Hue.from_bridge(bridge_info, application_key: application_key)
{:ok, _} = Kino.start_child({Hue.Bridge, name: HuePanel, client: client})
Enum.reduce_while(1..50, nil, fn _, _ ->
case Hue.Bridge.status(HuePanel) do
:live ->
{:halt, :live}
{:error, _} = err ->
{:halt, err}
status ->
Process.sleep(200)
{:cont, status}
end
end)
|> case do
:live -> :live
status -> raise "bridge did not reach :live, status: #{inspect(status)}"
end
Engine
Panel renders every dynamic region from the cache; controls report through
Panel.report_result/3, and the router calls the render_* functions when
the cache changes.
defmodule Panel do
@bridge HuePanel
# -- status line ---------------------------------------------------------
def report(ui, text) do
Kino.Frame.render(ui.status, Kino.Markdown.new("**#{text}**"))
end
def report_result(ui, label, :ok), do: report(ui, "#{label} ✓")
def report_result(ui, label, {:error, %Hue.Error{reason: reason}}),
do: report(ui, "#{label} — #{reason}")
def report_result(ui, label, {:error, other}),
do: report(ui, "#{label} — #{inspect(other)}")
# -- naming --------------------------------------------------------------
def name(type, rid), do: Hue.Bridge.name_of(@bridge, type, rid) || rid
# -- the lights a room's devices carry -----------------------------------
def lights_in_room(room) do
for %{"rid" => device_rid, "rtype" => "device"} <- room["children"] || [],
{:ok, device} <- [Hue.Bridge.fetch(@bridge, :device, device_rid)],
%{"rid" => light_rid, "rtype" => "light"} <- device["services"] || [],
{:ok, light} <- [Hue.Bridge.fetch(@bridge, :light, light_rid)],
do: light
end
# -- rooms ---------------------------------------------------------------
def group_state_line(type, group) do
case Hue.Bridge.grouped_light(@bridge, type, group["id"]) do
{:ok, gl} ->
on = if get_in(gl, ["on", "on"]), do: "on", else: "off"
bri = get_in(gl, ["dimming", "brightness"])
if bri, do: "#{on} · #{round(bri)}%", else: on
{:error, _} ->
"no lights"
end
end
def render_group_states(group_frames) do
for {{type, rid}, frame} <- group_frames do
case Hue.Bridge.fetch(@bridge, type, rid) do
{:ok, group} ->
Kino.Frame.render(frame, Kino.Markdown.new(group_state_line(type, group)))
{:error, _} ->
Kino.Frame.render(frame, Kino.Markdown.new("*(deleted)*"))
end
end
:ok
end
# -- lights --------------------------------------------------------------
def light_state_line(rid) do
case Hue.Bridge.fetch(@bridge, :light, rid) do
{:ok, light} ->
on = if get_in(light, ["on", "on"]), do: "on", else: "off"
bri = get_in(light, ["dimming", "brightness"])
xy = get_in(light, ["color", "xy"])
mirek = get_in(light, ["color_temperature", "mirek"])
[
on,
bri && "#{round(bri)}%",
xy && "xy #{Float.round(xy["x"], 3)},#{Float.round(xy["y"], 3)}",
mirek && "#{mirek} mirek",
connectivity_status(light)
]
|> Enum.reject(&is_nil/1)
|> Enum.join(" · ")
{:error, _} ->
"*(deleted)*"
end
end
def render_light_state(ui, rid) do
Kino.Frame.render(ui.light_state, Kino.Markdown.new(light_state_line(rid)))
end
# v2 has no reachability field on :light itself — "reachable" lives on the
# owning device's zigbee_connectivity service. Every step degrades to nil
# rather than crashing: a device with no such service (e.g. a zgp remote),
# a cache miss mid-topology-change, or simply the normal "connected" case,
# which is deliberately silent so the line doesn't get noisier for the
# common state.
defp connectivity_status(light) do
with %{"rid" => device_rid} <- light["owner"],
{:ok, device} <- Hue.Bridge.fetch(@bridge, :device, device_rid),
%{"rid" => zc_rid} <-
Enum.find(device["services"] || [], &(&1["rtype"] == "zigbee_connectivity")),
{:ok, zc} <- Hue.Bridge.fetch(@bridge, :zigbee_connectivity, zc_rid),
status when status != "connected" <- zc["status"] do
status
else
_ -> nil
end
end
# -- scenes --------------------------------------------------------------
def scene_action(light) do
action =
%{"on" => %{"on" => get_in(light, ["on", "on"])}}
|> then(fn a ->
if bri = get_in(light, ["dimming", "brightness"]),
do: Map.put(a, "dimming", %{"brightness" => bri}),
else: a
end)
|> then(fn a ->
cond do
mirek = get_in(light, ["color_temperature", "mirek"]) ->
Map.put(a, "color_temperature", %{"mirek" => mirek})
xy = get_in(light, ["color", "xy"]) ->
Map.put(a, "color", %{"xy" => xy})
true ->
a
end
end)
%{"target" => %{"rid" => light["id"], "rtype" => "light"}, "action" => action}
end
# -- management ------------------------------------------------------------
# A light's own name is deprecated in CLIP v2; the authoritative name lives
# on the device that owns it (`Hue.Bridge.Names`'s moduledoc), reached the
# same way the blink handler in Lights reaches it — via the light's "owner".
def light_owner_rid(rid) do
case Hue.Bridge.fetch(@bridge, :light, rid) do
{:ok, %{"owner" => %{"rid" => device_rid}}} -> device_rid
_ -> nil
end
end
def render_device_status(ui) do
status =
case Hue.Bridge.list(@bridge, :zigbee_device_discovery) do
{:ok, [discovery]} -> discovery["status"]
_ -> nil
end
Kino.Frame.render(ui.device_status, Kino.Markdown.new(status || "unknown"))
end
# -- activity ------------------------------------------------------------
@activity_cap 50
def describe(%Hue.Event{type: :error, resource_type: rt, rid: rid, data: %Hue.Error{reason: reason}}) do
"`#{rt}` **#{name(rt, rid)}** — write failed: #{reason}"
end
def describe(%Hue.Event{type: t, resource_type: rt, rid: rid, data: data}) do
what =
cond do
get_in(data, ["on", "on"]) == true -> "on"
get_in(data, ["on", "on"]) == false -> "off"
get_in(data, ["dimming", "brightness"]) -> "#{round(get_in(data, ["dimming", "brightness"]))}%"
get_in(data, ["metadata", "name"]) -> "renamed to #{get_in(data, ["metadata", "name"])}"
true -> to_string(t)
end
"`#{rt}` **#{name(rt, rid)}** — #{what}"
end
def render_activity(ui, history) do
history = Enum.take(history, @activity_cap)
Kino.Frame.render(ui.activity, Kino.Markdown.new(Enum.join(history, "\n\n")))
history
end
end
ui = %{
status: Kino.Frame.new(placeholder: false),
rooms: Kino.Frame.new(),
light_state: Kino.Frame.new(placeholder: false),
scenes: Kino.Frame.new(),
management: Kino.Frame.new(),
device_status: Kino.Frame.new(placeholder: false),
activity: Kino.Frame.new()
}
One row per room and zone — name, live state, On/Off, brightness — rebuilt whenever a room or zone is added or deleted, so a group created through Management shows up here without re-running this cell.
# Rebuilt (not re-rendered) on room/zone add/delete; group_frames_ref always
# holds the current generation, so the router's render_group_states call
# never reads a binding captured before the last rebuild.
{:ok, room_listeners} = Kino.start_child({Agent, fn -> [] end})
{:ok, group_frames_ref} = Kino.start_child({Agent, fn -> %{} end})
set_group = fn
:room, target, opts -> Hue.Room.set(HuePanel, target, opts)
:zone, target, opts -> Hue.Zone.set(HuePanel, target, opts)
end
rebuild_rooms = fn ->
Enum.each(Agent.get(room_listeners, & &1), &Kino.terminate_child/1)
Agent.update(room_listeners, fn _ -> [] end)
Agent.update(group_frames_ref, fn _ -> %{} end)
register = fn pid -> Agent.update(room_listeners, &[pid | &1]) end
{:ok, rooms} = Hue.Room.list(HuePanel)
{:ok, zones} = Hue.Zone.list(HuePanel)
groups =
Enum.map(rooms, &{:room, &1}) ++ Enum.map(zones, &{:zone, &1})
group_frames =
Map.new(groups, fn {type, g} -> {{type, g["id"]}, Kino.Frame.new(placeholder: false)} end)
room_rows =
for {type, g} <- groups do
gname = get_in(g, ["metadata", "name"])
state_frame = group_frames[{type, g["id"]}]
on = Kino.Control.button("On")
off = Kino.Control.button("Off")
bri = Kino.Control.form([brightness: Kino.Input.range("", min: 1, max: 100, default: 50)], report_changes: true)
register.(
Kino.listen(on, fn _ ->
Panel.report_result(ui, "#{gname} → on", set_group.(type, g["id"], on: true))
end)
)
register.(
Kino.listen(off, fn _ ->
Panel.report_result(ui, "#{gname} → off", set_group.(type, g["id"], on: false))
end)
)
register.(
Kino.listen(bri, fn %{data: %{brightness: b}} ->
Panel.report_result(ui, "#{gname} → #{round(b)}%", set_group.(type, g["id"], brightness: b))
end)
)
Kino.Layout.grid(
[Kino.Markdown.new("**#{gname}**"), state_frame, on, off, bri],
columns: 5
)
end
Kino.Frame.render(ui.rooms, Kino.Layout.grid(room_rows, columns: 1))
Agent.update(group_frames_ref, fn _ -> group_frames end)
Panel.render_group_states(group_frames)
end
rebuild_rooms.()
Pick a light, watch its state, and drive it — the picker's selection lives
in an Agent so the router knows which state line to refresh.
{:ok, lights} = Hue.Light.list(HuePanel)
light_options =
lights
|> Enum.map(fn l -> {l["id"], Panel.name(:light, l["id"])} end)
|> Enum.sort_by(&elem(&1, 1))
{:ok, selected_light} = Kino.start_child({Agent, fn -> elem(hd(light_options), 0) end})
picker = Kino.Control.form([light: Kino.Input.select("Light", light_options)], report_changes: true)
controls =
Kino.Control.form(
[
on: Kino.Input.checkbox("On", default: true),
brightness: Kino.Input.range("Brightness", min: 1, max: 100, default: 50),
color: Kino.Input.color("Colour", default: "#ffd08a"),
kelvin: Kino.Input.range("Kelvin", min: 2000, max: 6500, default: 2700),
use_color: Kino.Input.checkbox("Set colour (else colour temperature)", default: true),
transition: Kino.Input.number("Transition ms", default: 400, min: 0)
],
submit: "Apply"
)
blink = Kino.Control.button("Blink to identify")
Kino.listen(picker, fn %{data: %{light: rid}} ->
Agent.update(selected_light, fn _ -> rid end)
Panel.render_light_state(ui, rid)
end)
Kino.listen(controls, fn %{data: d} ->
rid = Agent.get(selected_light, & &1)
opts =
[on: d.on, brightness: d.brightness] ++
(if d.transition, do: [transition: round(d.transition)], else: []) ++
if(d.use_color, do: [color: d.color], else: [kelvin: round(d.kelvin)])
Panel.report_result(ui, "#{Panel.name(:light, rid)} → apply", Hue.Light.set(HuePanel, rid, opts))
end)
Kino.listen(blink, fn _ ->
rid = Agent.get(selected_light, & &1)
case Hue.Bridge.fetch(HuePanel, :light, rid) do
{:ok, light} ->
%{"rid" => device_rid} = light["owner"]
result = Hue.Resource.update(client, :device, device_rid, %{"identify" => %{"action" => "identify"}})
Panel.report_result(ui, "#{Panel.name(:light, rid)} → blink", result)
{:error, err} ->
Panel.report_result(ui, "blink", {:error, err})
end
end)
Panel.render_light_state(ui, elem(hd(light_options), 0))
lights_tab =
Kino.Layout.grid([picker, ui.light_state, controls, blink], columns: 1)
Scenes are room-owned: recall drives every light that room's devices carry to its captured state; a per-room form saves the current state as a new scene.
# Rebuilt (not re-rendered) on scene add/delete — Kino ties a control's
# lifetime to its creating pid/cell, not to whether it's still rendered, so
# the previous batch's listeners are terminated first or they'd leak.
{:ok, scene_listeners} = Kino.start_child({Agent, fn -> [] end})
save_scene = fn room, name ->
actions = room |> Panel.lights_in_room() |> Enum.map(&Panel.scene_action/1)
Hue.Resource.create(client, :scene, %{
"metadata" => %{"name" => name},
"group" => %{"rid" => room["id"], "rtype" => "room"},
"actions" => actions
})
end
rebuild_scenes = fn ->
# Accepted race: a click landing while this replaces its button is
# dropped — the reappearing row is the signal to re-click.
Enum.each(Agent.get(scene_listeners, & &1), &Kino.terminate_child/1)
Agent.update(scene_listeners, fn _ -> [] end)
register = fn pid -> Agent.update(scene_listeners, &[pid | &1]) end
{:ok, scenes} = Hue.Scene.list(HuePanel)
{:ok, rooms} = Hue.Room.list(HuePanel)
by_group = Enum.group_by(scenes, & &1["group"]["rid"])
room_ids = MapSet.new(rooms, & &1["id"])
scene_row = fn scene ->
name = get_in(scene, ["metadata", "name"])
button = Kino.Control.button("Recall")
# Renames arrive as :update (no rebuild), so a label can go stale here —
# recall keys on rid, not name, so it still recalls the right scene.
register.(
Kino.listen(button, fn _ ->
Panel.report_result(ui, "recall #{name}", Hue.Scene.recall(HuePanel, scene["id"]))
end)
)
Kino.Layout.grid([Kino.Markdown.new(name), button], columns: 2)
end
group_block = fn heading, group_scenes, save_room ->
rows = Enum.map(group_scenes, scene_row)
save_row =
if save_room do
form =
Kino.Control.form([name: Kino.Input.text("New scene from current state")], submit: "Save")
register.(
Kino.listen(form, fn %{data: %{name: name}} ->
result = save_scene.(save_room, name)
outcome = if match?({:ok, _}, result), do: :ok, else: result
Panel.report_result(ui, "save \"#{name}\"", outcome)
end)
)
[form]
else
[]
end
Kino.Layout.grid([Kino.Markdown.new("**#{heading}**")] ++ rows ++ save_row, columns: 1)
end
room_blocks =
for room <- rooms do
group_block.(get_in(room, ["metadata", "name"]), Map.get(by_group, room["id"], []), room)
end
leftover_blocks =
for {group_rid, group_scenes} <- by_group, not MapSet.member?(room_ids, group_rid) do
%{"rtype" => rtype} = hd(group_scenes)["group"]
group_type =
case rtype do
"room" -> :room
"zone" -> :zone
_ -> nil
end
heading = if group_type, do: Panel.name(group_type, group_rid), else: group_rid
group_block.(heading, group_scenes, nil)
end
Kino.Frame.render(ui.scenes, Kino.Layout.grid(room_blocks ++ leftover_blocks, columns: 1))
end
rebuild_scenes.()
The layer-1 escape hatch: rename, room/zone create and membership editing, device search and delete — tiered by consequence (instant · confirm click · type-to-confirm).
# Registered the instant each listener/Agent is created (not collected and
# stored in bulk at the end), so a rebuild that raises partway still leaves
# what it built registered for the next rebuild's terminate-first cleanup.
{:ok, management_listeners} = Kino.start_child({Agent, fn -> [] end})
# Reset to nil right after the terminate step (below), before any new
# control exists — so a crash before the end-of-rebuild update leaves
# refresh_membership pointing at nothing (a safe no-op), not a killed pid.
{:ok, membership_refs} = Kino.start_child({Agent, fn -> %{room: nil, zone: nil} end})
# Rooms group devices, zones group lights (confirmed against
# full_state.json) — membership editing follows that split rather than one
# editor pretending the two are the same shape.
render_members = fn type, rid, frame ->
content =
case Hue.Bridge.fetch(HuePanel, type, rid) do
{:ok, group} ->
case group["children"] || [] do
[] ->
"*(no members)*"
children ->
children
|> Enum.map(fn %{"rid" => crid, "rtype" => crtype} ->
Panel.name(Hue.Resource.type(crtype), crid)
end)
|> Enum.map_join("\n", &"- #{&1}")
end
{:error, _} ->
"*(deleted)*"
end
Kino.Frame.render(frame, Kino.Markdown.new(content))
end
refresh_membership = fn ->
case Agent.get(membership_refs, & &1.room) do
nil -> :ok
%{state: state, frame: frame} -> render_members.(:room, Agent.get(state, & &1.group), frame)
end
case Agent.get(membership_refs, & &1.zone) do
nil -> :ok
%{state: state, frame: frame} -> render_members.(:zone, Agent.get(state, & &1.group), frame)
end
end
rebuild_management = fn ->
Enum.each(Agent.get(management_listeners, & &1), &Kino.terminate_child/1)
Agent.update(management_listeners, fn _ -> [] end)
Agent.update(membership_refs, fn _ -> %{room: nil, zone: nil} end)
register = fn pid -> Agent.update(management_listeners, &[pid | &1]) end
archetypes = ~w(bedroom downstairs garden kitchen living_room office toilet tv other)
{:ok, rooms} = Hue.Room.list(HuePanel)
{:ok, zones} = Hue.Zone.list(HuePanel)
{:ok, scenes} = Hue.Scene.list(HuePanel)
{:ok, lights} = Hue.Light.list(HuePanel)
{:ok, devices} = Hue.Bridge.list(HuePanel, :device)
device_options =
devices
|> Enum.map(&{&1["id"], Panel.name(:device, &1["id"])})
|> Enum.sort_by(&elem(&1, 1))
light_options =
lights
|> Enum.map(&{&1["id"], Panel.name(:light, &1["id"])})
|> Enum.sort_by(&elem(&1, 1))
room_options =
rooms
|> Enum.map(&{&1["id"], get_in(&1, ["metadata", "name"])})
|> Enum.sort_by(&elem(&1, 1))
zone_options =
zones
|> Enum.map(&{&1["id"], get_in(&1, ["metadata", "name"])})
|> Enum.sort_by(&elem(&1, 1))
# ---- Rename ----
# CLIP v2's authoritative name lives on the device, not the light
# (Hue.Bridge.Names) — renaming a "light" here renames its owning device,
# so every light that device carries is renamed too.
light_rename_options =
for l <- lights, owner_rid = Panel.light_owner_rid(l["id"]), owner_rid do
{"light:#{owner_rid}", "light: #{Panel.name(:light, l["id"])}"}
end
rename_options =
Enum.uniq_by(light_rename_options, &elem(&1, 0)) ++
Enum.map(rooms, &{"room:#{&1["id"]}", "room: #{get_in(&1, ["metadata", "name"])}"}) ++
Enum.map(zones, &{"zone:#{&1["id"]}", "zone: #{get_in(&1, ["metadata", "name"])}"}) ++
Enum.map(scenes, &{"scene:#{&1["id"]}", "scene: #{get_in(&1, ["metadata", "name"])}"})
rename_block =
if rename_options == [] do
Kino.Markdown.new("*(nothing to rename)*")
else
# Kino forms can't repopulate one field from another's live selection,
# so type folds into the option value as "type:rid" (rids never
# contain a colon) instead of a type-select that could never filter.
rename_form =
Kino.Control.form(
[
resource: Kino.Input.select("Resource", rename_options),
name: Kino.Input.text("New name")
],
submit: "Apply"
)
register.(
Kino.listen(rename_form, fn %{data: %{resource: ref, name: new_name}} ->
[type_str, rid] = String.split(ref, ":", parts: 2)
type =
case type_str do
"light" -> :device
"room" -> :room
"zone" -> :zone
"scene" -> :scene
end
result = Hue.Resource.update(client, type, rid, %{"metadata" => %{"name" => new_name}})
Panel.report_result(ui, "rename to \"#{new_name}\"", result)
end)
)
Kino.Layout.grid([rename_form], columns: 1)
end
# ---- Rooms & zones: create ----
create_form =
Kino.Control.form(
[
name: Kino.Input.text("Name"),
archetype: Kino.Input.select("Archetype", Enum.map(archetypes, &{&1, &1})),
kind: Kino.Input.select("Kind", [{"room", "Room"}, {"zone", "Zone"}])
],
submit: "Create"
)
register.(
Kino.listen(create_form, fn %{data: %{name: name, archetype: archetype, kind: kind}} ->
type = if kind == "zone", do: :zone, else: :room
body = %{"metadata" => %{"name" => name, "archetype" => archetype}, "children" => []}
result = Hue.Resource.create(client, type, body)
outcome = if match?({:ok, _}, result), do: :ok, else: result
Panel.report_result(ui, "create #{kind} \"#{name}\"", outcome)
end)
)
# ---- Rooms & zones: room membership (devices) ----
{room_membership_block, room_ref} =
if room_options == [] or device_options == [] do
{Kino.Markdown.new("*(no rooms or no devices yet)*"), nil}
else
# Kino.Input.read/1 only works from the cell's own evaluation process,
# not a listener — so selection is tracked in this Agent instead.
{:ok, room_membership_state} =
Kino.start_child(
{Agent,
fn ->
%{group: elem(hd(room_options), 0), member: elem(hd(device_options), 0)}
end}
)
register.(room_membership_state)
room_group_select = Kino.Input.select("Room", room_options)
room_device_select = Kino.Input.select("Device", device_options)
room_members_frame = Kino.Frame.new(placeholder: false)
room_add_button = Kino.Control.button("Add to room")
room_remove_button = Kino.Control.button("Remove from room")
register.(
Kino.listen(room_group_select, fn %{value: rid} ->
Agent.update(room_membership_state, &Map.put(&1, :group, rid))
render_members.(:room, rid, room_members_frame)
end)
)
register.(
Kino.listen(room_device_select, fn %{value: rid} ->
Agent.update(room_membership_state, &Map.put(&1, :member, rid))
end)
)
register.(
Kino.listen(room_add_button, fn _ ->
# Measured against real hardware: writing a device into a new
# room auto-removes it from its old room in the same stroke, and
# rewrites the old room's scenes to drop that light's actions.
%{group: group_rid, member: member_rid} = Agent.get(room_membership_state, & &1)
result =
case Hue.Bridge.fetch(HuePanel, :room, group_rid) do
{:ok, room} ->
children = room["children"] || []
if Enum.any?(children, &(&1["rid"] == member_rid)) do
:ok
else
new_children = children ++ [%{"rid" => member_rid, "rtype" => "device"}]
Hue.Resource.update(client, :room, group_rid, %{"children" => new_children})
end
{:error, _} = err ->
err
end
Panel.report_result(
ui,
"add #{Panel.name(:device, member_rid)} to #{Panel.name(:room, group_rid)}",
result
)
end)
)
register.(
Kino.listen(room_remove_button, fn _ ->
%{group: group_rid, member: member_rid} = Agent.get(room_membership_state, & &1)
result =
case Hue.Bridge.fetch(HuePanel, :room, group_rid) do
{:ok, room} ->
new_children = Enum.reject(room["children"] || [], &(&1["rid"] == member_rid))
Hue.Resource.update(client, :room, group_rid, %{"children" => new_children})
{:error, _} = err ->
err
end
Panel.report_result(
ui,
"remove #{Panel.name(:device, member_rid)} from #{Panel.name(:room, group_rid)}",
result
)
end)
)
render_members.(:room, elem(hd(room_options), 0), room_members_frame)
block =
Kino.Layout.grid(
[room_group_select, room_device_select, room_add_button, room_remove_button, room_members_frame],
columns: 1
)
{block, %{state: room_membership_state, frame: room_members_frame}}
end
# ---- Rooms & zones: zone membership (lights) ----
{zone_membership_block, zone_ref} =
if zone_options == [] or light_options == [] do
{Kino.Markdown.new("*(no zones or no lights yet)*"), nil}
else
{:ok, zone_membership_state} =
Kino.start_child(
{Agent,
fn ->
%{group: elem(hd(zone_options), 0), member: elem(hd(light_options), 0)}
end}
)
register.(zone_membership_state)
zone_group_select = Kino.Input.select("Zone", zone_options)
zone_light_select = Kino.Input.select("Light", light_options)
zone_members_frame = Kino.Frame.new(placeholder: false)
zone_add_button = Kino.Control.button("Add to zone")
zone_remove_button = Kino.Control.button("Remove from zone")
register.(
Kino.listen(zone_group_select, fn %{value: rid} ->
Agent.update(zone_membership_state, &Map.put(&1, :group, rid))
render_members.(:zone, rid, zone_members_frame)
end)
)
register.(
Kino.listen(zone_light_select, fn %{value: rid} ->
Agent.update(zone_membership_state, &Map.put(&1, :member, rid))
end)
)
register.(
Kino.listen(zone_add_button, fn _ ->
%{group: group_rid, member: member_rid} = Agent.get(zone_membership_state, & &1)
result =
case Hue.Bridge.fetch(HuePanel, :zone, group_rid) do
{:ok, zone} ->
children = zone["children"] || []
if Enum.any?(children, &(&1["rid"] == member_rid)) do
:ok
else
new_children = children ++ [%{"rid" => member_rid, "rtype" => "light"}]
Hue.Resource.update(client, :zone, group_rid, %{"children" => new_children})
end
{:error, _} = err ->
err
end
Panel.report_result(
ui,
"add #{Panel.name(:light, member_rid)} to #{Panel.name(:zone, group_rid)}",
result
)
end)
)
register.(
Kino.listen(zone_remove_button, fn _ ->
%{group: group_rid, member: member_rid} = Agent.get(zone_membership_state, & &1)
result =
case Hue.Bridge.fetch(HuePanel, :zone, group_rid) do
{:ok, zone} ->
new_children = Enum.reject(zone["children"] || [], &(&1["rid"] == member_rid))
Hue.Resource.update(client, :zone, group_rid, %{"children" => new_children})
{:error, _} = err ->
err
end
Panel.report_result(
ui,
"remove #{Panel.name(:light, member_rid)} from #{Panel.name(:zone, group_rid)}",
result
)
end)
)
render_members.(:zone, elem(hd(zone_options), 0), zone_members_frame)
block =
Kino.Layout.grid(
[zone_group_select, zone_light_select, zone_add_button, zone_remove_button, zone_members_frame],
columns: 1
)
{block, %{state: zone_membership_state, frame: zone_members_frame}}
end
rooms_zones_block =
Kino.Layout.grid(
[
Kino.Markdown.new("**Create a room or zone**"),
create_form,
Kino.Markdown.new(
"**Membership.** Adding a device to a room removes it from its old " <>
"room automatically, and the old room's scenes are rewritten to " <>
"drop the departed light."
),
Kino.Markdown.new("**Rooms**"),
room_membership_block,
Kino.Markdown.new("**Zones**"),
zone_membership_block
],
columns: 1
)
# ---- Devices: search ----
search_button = Kino.Control.button("Search for new devices")
register.(
Kino.listen(search_button, fn _ ->
# zigbee_device_discovery is a bridge singleton; a joining device
# announces itself on Activity like any other event — no separate UI.
result =
case Hue.Resource.list(client, :zigbee_device_discovery) do
{:ok, [discovery]} ->
Hue.Resource.update(client, :zigbee_device_discovery, discovery["id"], %{
"action" => %{"action_type" => "search"}
})
{:ok, other} ->
{:error, {:unexpected_zigbee_device_discovery, other}}
{:error, _} = err ->
err
end
Panel.report_result(ui, "search for new devices", result)
end)
)
Panel.render_device_status(ui)
# ---- Devices: delete ----
device_delete_block =
if device_options == [] do
Kino.Markdown.new("*(no devices)*")
else
# Type-to-confirm, not a click or checkbox: recovering an unpaired
# bulb can require a physical reset.
device_delete_form =
Kino.Control.form(
[
device: Kino.Input.select("Device", device_options),
confirm_name: Kino.Input.text("Type the device's name to confirm")
],
submit: "Delete"
)
register.(
Kino.listen(device_delete_form, fn %{data: %{device: rid, confirm_name: typed}} ->
case Hue.Bridge.fetch(HuePanel, :device, rid) do
{:ok, device} ->
current_name = get_in(device, ["metadata", "name"])
if typed == current_name do
Panel.report_result(
ui,
"delete #{current_name}",
Hue.Resource.delete(client, :device, rid)
)
else
Panel.report(ui, "delete #{current_name} — typed name did not match, refused")
end
{:error, _} = err ->
Panel.report_result(ui, "delete device", err)
end
end)
)
Kino.Layout.grid([device_delete_form], columns: 1)
end
devices_block =
Kino.Layout.grid(
[
Kino.Markdown.new(
"**Search.** New devices announce themselves on the Activity feed " <>
"as they join."
),
search_button,
ui.device_status,
Kino.Markdown.new(
"**Delete.** Unpairs the bulb. Recovery can require physically " <>
"resetting it."
),
device_delete_block
],
columns: 1
)
# ---- Delete (scene/room/zone) ----
delete_options =
Enum.map(rooms, &{"room:#{&1["id"]}", "room: #{get_in(&1, ["metadata", "name"])}"}) ++
Enum.map(zones, &{"zone:#{&1["id"]}", "zone: #{get_in(&1, ["metadata", "name"])}"}) ++
Enum.map(scenes, &{"scene:#{&1["id"]}", "scene: #{get_in(&1, ["metadata", "name"])}"})
delete_block =
if delete_options == [] do
Kino.Markdown.new("*(nothing to delete)*")
else
delete_form =
Kino.Control.form(
[
resource: Kino.Input.select("Resource", delete_options),
confirm: Kino.Input.checkbox("Confirm delete")
],
submit: "Delete"
)
register.(
Kino.listen(delete_form, fn %{data: %{resource: ref, confirm: confirmed}} ->
[type_str, rid] = String.split(ref, ":", parts: 2)
type =
case type_str do
"room" -> :room
"zone" -> :zone
"scene" -> :scene
end
name = Panel.name(type, rid)
if confirmed do
Panel.report_result(ui, "delete #{name}", Hue.Resource.delete(client, type, rid))
else
Panel.report(ui, "delete #{name} — refused, confirm not checked")
end
end)
)
Kino.Layout.grid([delete_form], columns: 1)
end
management_tab =
Kino.Layout.tabs([
{"Rename", rename_block},
{"Rooms & zones", rooms_zones_block},
{"Devices", devices_block},
{"Delete", delete_block}
])
Kino.Frame.render(ui.management, management_tab)
Agent.update(membership_refs, fn _ -> %{room: room_ref, zone: zone_ref} end)
end
rebuild_management.()
{:ok, _router} =
Kino.start_child(
{Task,
fn ->
:ok = Hue.Bridge.subscribe(HuePanel)
Stream.repeatedly(fn -> receive do: ({:hue, e} -> e) end)
|> Enum.reduce([], fn event, history ->
history = [Panel.describe(event) | history] |> then(&Panel.render_activity(ui, &1))
if event.resource_type in [:room, :zone] and event.type in [:add, :delete] do
try do
rebuild_rooms.()
rescue
e -> Panel.report(ui, "rooms rebuild failed: #{Exception.message(e)}")
catch
:exit, reason -> Panel.report(ui, "rooms rebuild failed: #{inspect(reason)}")
end
end
if event.resource_type in [:grouped_light, :room, :zone],
do: Panel.render_group_states(Agent.get(group_frames_ref, & &1))
if event.resource_type == :light and event.rid == Agent.get(selected_light, & &1),
do: Panel.render_light_state(ui, event.rid)
if event.resource_type == :zigbee_connectivity,
do: Panel.render_light_state(ui, Agent.get(selected_light, & &1))
if event.resource_type == :scene and event.type in [:add, :delete] do
try do
rebuild_scenes.()
rescue
e -> Panel.report(ui, "scenes rebuild failed: #{Exception.message(e)}")
catch
:exit, reason -> Panel.report(ui, "scenes rebuild failed: #{inspect(reason)}")
end
end
if event.resource_type in [:room, :zone, :scene, :device, :light] and
event.type in [:add, :delete] do
try do
rebuild_management.()
rescue
e -> Panel.report(ui, "management rebuild failed: #{Exception.message(e)}")
catch
:exit, reason -> Panel.report(ui, "management rebuild failed: #{inspect(reason)}")
end
end
# Runs on every :room/:zone event, not just add/delete — a
# membership edit itself arrives as :update.
if event.resource_type in [:room, :zone] do
try do
refresh_membership.()
rescue
e -> Panel.report(ui, "membership refresh failed: #{Exception.message(e)}")
catch
:exit, reason -> Panel.report(ui, "membership refresh failed: #{inspect(reason)}")
end
end
if event.resource_type == :zigbee_device_discovery,
do: Panel.render_device_status(ui)
history
end)
end}
)
:ok
Panel
Kino.Layout.tabs([
{"Rooms", ui.rooms},
{"Lights", lights_tab},
{"Scenes", ui.scenes},
{"Management", ui.management},
{"Activity", ui.activity}
])
|> then(&Kino.Layout.grid([ui.status, &1], columns: 1))