Finds Hue bridges, by three methods, because no single one is sufficient.
| Method | Works on | Fails on |
|---|---|---|
| mDNS | A flat home LAN — the common case | Routed subnets, VLANs, containers |
| Cloud | Anything sharing a public IP | No internet access |
| Manual | Always | — |
mDNS is link-local multicast and does not cross a router. On a network where
the bridge sits behind a different subnet than the client — double-NAT behind
an ISP router, an IoT VLAN, a container — it returns nothing at all, with no
error to explain why. This library was developed on exactly such a network:
host on 192.168.68.0/22, bridge on 192.168.178.146, a router in between.
avahi-browse found nothing; the cloud endpoint answered instantly. That case
is common enough that cloud discovery runs by default; disable it with
cloud: false.
Cloud discovery contacts discovery.meethue.com, which matches on public IP
and returns local addresses. No credentials are involved, but Signify learns
the requesting IP.
Whatever the method, every candidate is confirmed by identify/2 before it is
returned, and confirming is also where the certificate is pinned.
First contact is the trust decision
A bridge's certificate cannot be verified — see Hue.Transport for why — so
the library pins it instead, SSH-style, and identify/2 is the single moment
that pin is chosen. It:
- reads the certificate with
Hue.Transport.capture_certificate/3, - requests
GET /api/configpinned to that certificate, so the bridge id it learns comes from the same endpoint the pin describes, - rejects
BSB001, the v1 bridge, which has no CLIP v2 API at all, - checks the certificate's common name against the reported bridge id, and
- returns a
Hue.Bridge.Infocarrying host, port, bridge id, model, and fingerprint.
Everything after that — Hue.from_bridge/2, Hue.Pairing.pair/2,
Hue.Resource — is pinned to that fingerprint.
Step 2 is why the capture comes first rather than after. A bridge id read over
an unverified connection describes whatever answered; read over a connection
pinned to the certificate just captured, it describes the holder of that
certificate. The two statements then have to agree in step 4, and if they do
not, identify/2 returns :bridge_identity_mismatch rather than storing a
pin under an identity it does not belong to.
Stubbed requests do not produce a pin
Pass :plug (or :adapter) and no TLS handshake happens, so there is no
certificate to capture and none is invented: the returned bridge has
fingerprint: nil, and a client built from it is unverified and says so.
Nothing a stub can put on the wire produces a pin.
A :fingerprint passed alongside a stub is still carried onto the result,
because that is a value the caller supplied rather than one this library
concluded from a stubbed connection. It is the same option identify/2
documents for re-checking an already-trusted bridge, and it is not
attacker-controllable.
Summary
Functions
Reads mDNS answers from an already-open UDP socket until timeout
milliseconds have passed, and returns what they name.
Finds every reachable bridge, confirms each one, and pins its certificate.
Confirms a candidate address is a CLIP v2 bridge, pins its certificate, and captures its identity.
Whether a certificate's common name corroborates the bridge id the bridge reported. Pure.
Deduplicates candidates, preferring the locally-discovered record.
Parses the cloud endpoint's response body. Pure.
Parses an mDNS response packet into addresses. Pure.
The mDNS query this library multicasts: one PTR question for
_hue._tcp.local. Pure.
Functions
@spec collect_answers(:gen_udp.socket(), non_neg_integer()) :: [Hue.Bridge.Info.t()]
Reads mDNS answers from an already-open UDP socket until timeout
milliseconds have passed, and returns what they name.
timeout is a budget for the whole collection, not for each receive. The
deadline is absolute for that reason: a responder answers across several
packets, and restarting the clock on each one turns a 5s budget into 5s per
packet, with no bound on the total.
Separate from the socket that sends the query so that the collection loop can be driven over an ordinary unicast socket, which is the only way to test it without standing up a multicast responder.
@spec discover(keyword()) :: {:ok, [Hue.Bridge.Info.t()]} | {:error, Hue.Error.t()}
Finds every reachable bridge, confirms each one, and pins its certificate.
Runs the enabled methods concurrently, merges what they found, and confirms
each candidate with identify/2. Only confirmed bridges come back, so every
Hue.Bridge.Info in the list carries a bridge id and — unless the request was
stubbed — a fingerprint.
Options
:cloud— setfalseto skip the cloud endpoint. Defaults totrue.:mdns— setfalseto skip multicast. Defaults totrue.:timeout— the budget in milliseconds for one phase of one thing. Defaults to5000. It bounds mDNS collection as a whole — answers are collected until an absolute deadline rather than restarting the clock per packet — and it bounds each ofidentify/2's3phases. It is not a wall-clock ceiling ondiscover/1: finding runs concurrently with a budget of its own, and confirmation follows it, so a run against candidates that all stall takes roughly4times the budget. Candidates are confirmed concurrently, so their number does not extend it.
Every other option is forwarded to identify/2, and from there to Hue.new/2
and Req.new/1.
Candidates that could not be confirmed
A candidate that answers neither the certificate capture nor /api/config is
not returned — the list would otherwise contain entries with no bridge id and
no pin, and Hue.from_bridge/2 would happily build unverified clients from
them. It is not discarded silently either: each one 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 and a caller who cannot
tell them apart is stuck.
@spec identify( String.t(), keyword() ) :: {:ok, Hue.Bridge.Info.t()} | {:error, Hue.Error.t()}
Confirms a candidate address is a CLIP v2 bridge, pins its certificate, and captures its identity.
GET /api/config needs no application key, which is what makes trust
bootstrappable: it yields the bridge id needed to make sense of the
certificate's common name.
Options
:port— defaults to443.:timeout— milliseconds allowed per phase, of which there are3: capturing the certificate, connecting and handshaking for the request, and waiting for the response. Defaults to5000, so the worst case is that many times the budget. It sets:receive_timeoutandconnect_options[:timeout]unless the caller set them, because between them those are what bound the request —:receive_timeoutalone leaves connecting and the handshake on Finch's five-second default, which ignores the budget entirely.:discovered_by— recorded on the result. Defaults to:manual.:fingerprint— an already-trusted pin. Given one, no certificate is captured and no trust decision is made: the request is pinned to it and fails if the bridge presents anything else. This is how to re-check a bridge you already know without re-deciding whether to trust it.
Every other option is forwarded to Hue.new/2 and from there to Req.new/1.
Errors
:unsupported_bridge— aBSB001, the v1 bridge.:bridge_identity_mismatch— the certificate's common name is not the bridge id the bridge reported.:unexpected_response— a 200 that is not a bridge configuration.- anything
Hue.Error.from_response/4orHue.Error.transport/2produces.
Whether a certificate's common name corroborates the bridge id the bridge reported. Pure.
On real hardware the common name is the bridge id, so these are two
independent statements of the same fact and they should agree. Compared
case-insensitively: /api/config reports the id in upper case, the cloud
endpoint in lower case, and a certificate in whichever its issuer chose.
A nil on either side is not a disagreement. Absent corroboration and
contradicted corroboration are different situations, and only the second one
is evidence of anything.
@spec merge([Hue.Bridge.Info.t()]) :: [Hue.Bridge.Info.t()]
Deduplicates candidates, preferring the locally-discovered record.
A bridge found by both methods is one bridge. mDNS wins because it proves link-local reachability, which the cloud endpoint does not — it reports an address that the client may have no route to at all. Fields the winner is missing are filled from the records it displaced, so preferring mDNS never costs the bridge id only the cloud endpoint knows.
Candidates that carry no bridge id — everything mDNS produces, which yields addresses and nothing else — are deduplicated by host instead, and dropped when a record that does carry an id already names that host.
@spec parse_cloud_response(list()) :: [Hue.Bridge.Info.t()]
Parses the cloud endpoint's response body. Pure.
The live shape, captured 2026-08-06:
[{"id":"001788fffeae1b58","internalipaddress":"192.168.178.146","port":443}]Note id, lower case, where /api/config reports bridgeid upper case.
Upcased here so the two agree.
Parses an mDNS response packet into addresses. Pure.
Reads the answer, authority, and additional sections. A responder answers
_hue._tcp.local with a PTR to the service instance and puts the SRV and A
records in the additional section, so a parser that reads only anlist finds
the instance name and no address at all.
Only A records are extracted: the CLIP v2 API is IPv4-only in practice, and
the SRV port is always 443, which is Hue.Bridge.Info's default.
Decoding is :inet_dns, which is undocumented kernel internals. Verified
against OTP 28: decode/1 defaults to mDNS mode, returns {:ok, dns_rec} or
{:error, :formerr}, and rr/2 reads :type and :data off a dns_rr.
A packet it cannot decode yields [] — a malformed multicast packet from
something else on the LAN is not this library's problem to report.
@spec query_packet() :: binary()
The mDNS query this library multicasts: one PTR question for
_hue._tcp.local. Pure.
Built with :inet_dns's make_* constructors rather than as positional
tuples. The record shapes are undocumented and they move: on OTP 28
dns_header carries nine fields and dns_query four, so the tuples this was
first written with raise FunctionClauseError. The constructors are the only
form that does not have to be re-verified against every release.