hue

An Elixir client for the Philips Hue CLIP v2 API — local, event-driven, certificate-pinned, and gamut-correct.

Three Hue clients already exist on Hex. All three target the v1 API that Signify has since deprecated, and all three are abandoned: huex (last released 2018), exhue (2018), and hue_sdk (2022). This one speaks CLIP v2, the API the bridge actually documents today — over TLS, with the bridge's certificate pinned rather than ignored.

Everything it claims about the protocol was measured against a real BSB002 running firmware 1.78.0, not read off a documentation page. Where a measurement contradicted the documentation, the measurement is what the code does, and the moduledoc says so.

Install

def deps do
[{:hue, "~> 0.1"}]
end

This is not on Hex yet. Until it is, depend on the tag:

def deps do
[{:hue, github: "ShawnMcCool/hue-ex", tag: "v0.1.0"}]
end

Requires Elixir 1.17 or later.

Quickstart

The order below is the real one, and step four is the one people skip.

1. Find the bridge.

{:ok, [bridge]} = Hue.Discovery.discover()
bridge
# %Hue.Bridge.Info{
# host: "192.168.178.146",
# port: 443,
# bridge_id: "001788FFFEAE1B58",
# model_id: "BSB002",
# fingerprint: "21cdf48f…",
# discovered_by: :cloud
# }

If you already know the address, skip discovery and confirm it directly — Hue.Discovery.identify("192.168.178.146") returns the same struct.

2. Press the round link button on the bridge. Physically. This is the authorisation step; there is no other one.

3. Pair, within about thirty seconds of pressing it.

{:ok, keys} = Hue.Pairing.pair_when_pressed(bridge)
keys
# %{
# application_key: "40-character-secret",
# clientkey: "32-character-hex"
# }

pair_when_pressed/2 blocks and retries, so you can start it and then walk over and press the button. Hue.Pairing.pair/2 is the single-shot version, and returns {:error, %Hue.Error{reason: :link_button_not_pressed}} when it is too early.

4. Store the application key and the fingerprint.

%{
host: bridge.host,
bridge_id: bridge.bridge_id,
fingerprint: bridge.fingerprint,
application_key: keys.application_key
}

Everybody stores the application key, because nothing works without it. The fingerprint is the half that gets dropped, and a client rebuilt without it verifies nothing — it still works, silently, which is exactly why it goes unnoticed. Persist both together or the pin does not survive a restart.

Treat the application key like a password. It is a bearer credential with full control of the bridge, and it never expires on its own.

5. Build a client and make a request.

{:ok, client} =
Hue.new("192.168.178.146",
application_key: application_key,
fingerprint: fingerprint
)
{:ok, lights} = Hue.Resource.list(client, :light)
:ok = Hue.Resource.update(client, :light, rid, %{"on" => %{"on" => true}})

If you still have the Hue.Bridge.Info from discovery, Hue.from_bridge/2 carries the id, port, and pin across for you:

{:ok, client} = Hue.from_bridge(bridge, application_key: application_key)

Every CLIP v2 resource type goes through Hue.Resourcelight, room, scene, grouped_light, behavior_instance, and the thirty-odd others — so nothing is ever blocked on a missing wrapper. list/3 and create/4 return {:ok, data}, get/4 returns {:ok, resource}, and update/5 and delete/4 return a bare :ok, because the bridge answers a write with only the rid you already had. The state change itself arrives on the eventstream.

Layer 2 — the live model

Layer 1 is a protocol client: every call is a request. That is the right shape for a script and the wrong shape for an application, because "dim the living room" over layer 1 means listing rooms, finding one by name, walking its services, and then writing — several round trips, every time.

Hue.Bridge keeps a live model instead. It fetches the full state once, follows the eventstream, and answers reads from ETS.

children = [
{Hue.Bridge, name: MyApp.Hue, client: client}
]

It never starts itself. You place it in your supervision tree, the way you place Finch or Redix.

{:ok, light} = Hue.Light.get(MyApp.Hue, "Desk Lamp")
:ok = Hue.Light.set(MyApp.Hue, "Iris", color: "#ff8800", brightness: 40)
:ok = Hue.Room.set(MyApp.Hue, "Living Room", on: false)
:ok = Hue.Scene.recall(MyApp.Hue, "Relax")

Targets are names or rids, interchangeably. Hue.Light, Hue.Room, Hue.Zone, and Hue.Scene cover the name-addressable operations; Hue.Bridge.write/4 and Hue.Resource remain available underneath for anything not wrapped.

Reads do not touch the process

Hue.Light.get/2 is an :ets.lookup in your process. It does not message the bridge, does not serialise against other readers, and does not queue behind an eventstream frame being merged.

Writes are the opposite, deliberately: they go through the process because that is the only place coalescing and Hue's rate limits can live. Twenty slider drags on one light become one request carrying the last value.

set returns :ok once the write is queued, not once it is applied — the PUT's response is not the truth, and the state change arrives as an event a moment later. Pass await: true when you need confirmation:

:ok = Hue.Light.set(MyApp.Hue, "Iris", on: true, await: true)

A room or zone with no grouped_light service — two of the six rooms on the reference bridge are like this — answers {:error, %Hue.Error{reason: :no_grouped_light}} rather than inventing one to write to. A capability mismatch, like brightness: against a non-dimmable light, is caught from the cache before the request leaves at all.

Subscribing

Hue.Bridge.subscribe(MyApp.Hue, type: :button)
def handle_info({:hue, %Hue.Event{} = event}, state), do: ...

Filtering happens at the registry. A process waiting on button presses is not woken when a scene changes nineteen lights. Subscribe with no filter for everything, or with name: / rid: for one resource.

The failure that is silent

A dead eventstream does not announce itself. Every read keeps answering, and every answer is quietly stale — the bridge sends no keepalive, so an idle stream is indistinguishable from a dead one at the protocol level. Hue.Bridge detects it at the transport layer and reconnects with backoff, refetching the full state each time rather than resuming from an event id.

Attach to [:hue, :stream, :disconnected] if you want to know. It is the single most useful thing to monitor about this library.

Telemetry

[:hue, :request, :start | :stop | :exception] duration, method, path, result
[:hue, :pairing, :start | :stop | :exception] duration, method, path, result
[:hue, :sync, :stop] duration, resource_count
[:hue, :stream, :connected] downtime
[:hue, :stream, :disconnected] reason
[:hue, :write, :coalesced] collapsed_count, type, rid
[:hue, :write, :failed] type, rid, reason

Corrected before 0.2.0. This table previously listed type, rid, status for [:hue, :request, *]. That was never what Hue.Resource emits: its span metadata is %{method:, path:} at :start, with :result (:ok or :error) added at :stop — no type, rid, or status key ever appears. rid in particular would be useful (a handler wanting to know which light a slow request concerned currently cannot), but adding it is a behaviour change to what the span carries, not a documentation fix — noted as a possible follow-up, not done here.

Corrected again, closing the final review of layer 2. The two write rows were missing type and rid, which Hue.Bridge.Server has carried in both events' metadata since each was introduced — this table just never listed them.

[:hue, :request, *] and [:hue, :pairing, *] (layer 1, via :telemetry.span/3) fire around every Hue.Resource call and every Hue.Pairing.pair/2 call, respectively. Everything else is layer 2, emitted by the Hue.Bridge process for whichever bridge it concerns (metadata.bridge is the name you gave Hue.Bridge's :name option).

[:hue, :stream, :disconnected] is the one worth attaching to unconditionally. This library's characteristic failure mode is silent: a dropped eventstream leaves every read answering, and every answer quietly stale, because there is no keepalive to miss and reads never ask the process how current its data is. That event is the only thing that reveals it.

Trust model

Be clear-eyed about this one, because the honest version is genuinely useful and the marketing version is not.

A Hue bridge presents a certificate whose common name is its bridge id — not a hostname — with no subjectAltName, signed by a Signify root that is neither sent on the wire nor published anywhere you can download it. There is no name to match and no certificate authority to bundle. Ordinary TLS verification cannot succeed against a Hue bridge, by construction.

So this library pins instead. The first connection to a bridge records the SHA-256 fingerprint of the certificate it presented, and every connection after that requires the same one. That is the SSH host-key model, and it has the same shape of guarantee:

For comparison: aiohue, the client Home Assistant ships and the most mature implementation in any language, sets ssl=False and verifies nothing at all. That is the de facto industry practice, and it is a defensible reading of a protocol that makes verification this hard. Pinning is materially stronger — it turns an indefinitely open window into a single moment — but it is stronger than nothing rather than strong in the way a web PKI chain is strong.

:certificate_changed means one of two things: your traffic is being intercepted, or the bridge was factory-reset and issued a new certificate. This library cannot tell those apart, and neither can you from the error alone. It refuses the connection and leaves the decision to you. Re-trusting is deliberate: capture the new fingerprint with Hue.Discovery.identify/2 and store it, knowingly.

verify: :none is available if you want aiohue's behaviour. It is never the default, and Hue.new/2 raises rather than quietly accepting a TLS option it cannot honour — in a library whose job is to verify, a rejected option must not be mistakable for an applied one.

One implementation note worth knowing, because it is a trap: TLS session resumption silently bypasses a pin. A resumed session presents no certificate, so verify_fun is never called and the fingerprint is never compared. Every connection this library makes therefore performs a full handshake (reuse_sessions: false, session_tickets: :disabled). The cost is a few round trips on a LAN. The alternative is a pin that stops being enforced after the first connection, which is no pin at all. See Hue.Transport.

Discovery on real networks

mDNS is link-local multicast. It does not cross a router, and when it fails it fails silently — no error, no packet, just an empty list that looks exactly like "you have no bridge". Double-NAT behind an ISP router, an IoT VLAN, and a container all land in this case, and it is common enough that this library was developed on such a network: host on 192.168.68.0/22, bridge on 192.168.178.146. avahi-browse found nothing at all; the cloud endpoint answered instantly.

So Hue.Discovery.discover/1 runs mDNS and the cloud endpoint concurrently by default and merges the results, preferring the mDNS record because it proves link-local reachability that a cloud answer does not.

Hue.Discovery.discover(cloud: false) # mDNS only
Hue.Discovery.discover(mdns: false) # cloud only

Cloud discovery contacts discovery.meethue.com, which matches on your public IP and returns local addresses. No credentials are sent and no bridge data leaves your network, but Signify learns your IP address. If that is not a trade you want, pass cloud: false and accept that on a routed network you will have to supply the host yourself.

Every candidate, whichever method found it, is confirmed by Hue.Discovery.identify/2 before it is returned, and confirming is where the certificate is pinned. A candidate that cannot be confirmed is not returned — but it is logged at :warning with its address and the reason, because "found it, could not reach it" and "found nothing" call for completely different fixes.

The eventstream

GET /eventstream/clip/v2 pushes every state change, which is what makes a correct local model possible without polling.

client
|> Hue.Events.stream()
|> Enum.each(fn %Hue.Event{} = event ->
IO.inspect({event.type, event.resource_type, event.rid, event.data})
# {:update, :light, "8b8ea3f2-…", %{"on" => %{"on" => true}, …}}
end)

stream/2 returns a lazy Enumerable and starts no process of its own. The connection opens when the stream is first enumerated, in whichever process enumerates it, and closes when the stream stops — including when the consumer halts early, so Enum.take(stream, 1) releases the socket rather than leaking it. Where that enumeration runs is your decision: a Task, a GenServer doing nothing else, whatever fits your supervision tree.

It does not reconnect. A dropped connection raises Hue.Error out of the enumeration and that is the end of it. Reconnecting is the caller's job, because the caller is the only thing that knows whether the events it already handled make a fresh request the right move. Req's retry is force-disabled on this request for the same reason: a partly-read stream cannot be resumed by repeating it, and each abandoned attempt leaves its own chunks in your mailbox.

Silence is not evidence of anything. The bridge sends a : hi comment on connect and then, measured, nothing at all for a hundred seconds on an idle stream. There is no keepalive to miss, so an idle stream is protocol-indistinguishable from a dead one. stream/2 waits forever by default. You can set receive_timeout: 30_000 and treat silence as death, and that is a reasonable policy — just know that it will sometimes fire on a perfectly healthy bridge that simply had nothing to say. No liveness policy over this protocol is correct; pick the way you prefer to be wrong.

Hue.Events.decode/1 and decode_stream/1 decode bytes without opening anything, if you would rather own the connection yourself. Both handle a frame split across chunk boundaries at any byte, which is the bug class that eats hand-rolled SSE parsers.

Colour

You think in hex, RGB, and Kelvin. Hue speaks CIE xy and mirek, and the representable range differs per light: gamut arrives as three arbitrary xy primaries that vary by model, and a single bridge commonly hosts several kinds at once. A colour outside a particular light's triangle is not approximately right, it is unreachable on that light.

So every conversion here takes the target light's own resource and clamps into its own gamut:

{:ok, light} = Hue.Resource.get(client, :light, rid)
{:ok, body} = Hue.Color.payload("#ff8800", light)
# %{"color" => %{"xy" => %{"x" => 0.5336217655640444, "y" => 0.41447683322855305}}}
:ok = Hue.Resource.update(client, :light, rid, body)
{:ok, mirek} = Hue.Color.mirek_for(2700, light) # clamped to this light's range
:ok = Hue.Resource.update(client, :light, rid, %{"color_temperature" => %{"mirek" => mirek}})

Hue.Color.to_xy/2 gives you the clamped pair without the payload wrapper, and accepts "#ff8800", {255, 136, 0}, or an explicit {:xy, x, y}.

Going the other way, Hue.Color.to_hex/1 is for swatches only. An xy pair carries no luminance, so converting back invents one — the maximum — and the result is always the brightest colour of that hue. #000000, #808080, and #ffffff are all achromatic, share one chromaticity, and all come back "#ffffff". Use it to show which colour a light is set to. Never to show how bright.

Bridge-data problems come back as errors (:not_color_capable for a light with no colour support, :invalid_gamut for gamut data that cannot be parsed). Input that could never have been valid — an RGB component outside 0..255, a hex string that is not hex, a non-positive Kelvin — raises. That split holds throughout the library: Hue.Error means something outside the process refused you; a caller bug raises.

Scope

Layer 1 (Hue, Hue.Client, Hue.Resource, and friends) is a stateless protocol client: it starts no processes, holds no state between calls, caches nothing, and reconnects nothing. It reaches every CLIP v2 resource type generically, so it is never the thing blocking you. Layer 2 (Hue.Bridge and the name-addressable modules above it) opts into exactly one supervised process per bridge you configure — see "Layer 2" above. Neither layer starts anything you did not ask for.

What this library deliberately does not do:

Testing

mix test # offline, no network, 516 tests
mix test --include live # adds 17 tests against real hardware
mix precommit # compile --warnings-as-errors, format, credo --strict, dialyzer, test

The offline suite runs against bytes recorded from a real BSB002 — a full 178-resource state dump, real eventstream frames, and the bridge's actual HTML 403 page — plus synthetic certificates generated at test time for the pinning paths, and Hue.Stub's function-plug bridge for Hue.Bridge. It touches no network.

Point the live suite at your own bridge:

HUE_HOST=192.168.178.146 HUE_KEY=your-application-key mix test --include live

It is excluded by default, so an ordinary mix test never reaches for the network. The layer-1 tests in it are read-only — every request is a GET; it never pairs, never writes, never deletes. The layer-2 tests are read-and-restore: Hue.Bridge exists to be written to, so those tests toggle a real light or nudge a real brightness and use on_exit/1 to put it back before the test process ends, win or lose. Nothing it touches is left changed.

The live suite earns its keep. The session-resumption bypass described above was found by a live test and cannot be reproduced against fixtures, because each synthetic listener gets a fresh port and so has no session to resume — and the layer-2 tests exist to check Hue.Bridge.Writes's coalescing and pacing against a real bridge's actual rate limit, rather than trusting that Hue.Stub's model of one agrees with it.

Licence

MIT. See LICENSE.