Decodes the bridge's Server-Sent Events stream.
GET /eventstream/clip/v2 pushes every state change, which is what makes a
correct local model possible without polling. Three properties of the wire
format catch naive implementations, all observed against a BSB002 on
2026-08-06:
- It is a double array. One SSE frame carries a list of envelopes, and each envelope carries a list of changed resources. One frame is not one change, at either level.
- Deltas are partial. Only changed fields arrive, plus identity. See
Hue.Event. - A frame can be split across TCP chunks at any byte. That is the bug
class this module exists to prevent, and the reason
server_sent_eventsis a dependency rather than forty lines of hand-rolled parsing.
decode/1 is for a complete buffer. decode_stream/1 takes an enumerable of
chunks and carries parser state across them. stream/2 opens the connection
and gives back the events lazily.
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. Nothing here
can tell them apart, and nothing here pretends to: stream/2 waits forever by
default. Liveness is the caller's to define — see stream/2's
:receive_timeout, and expect whatever you set to fire on a healthy bridge
that simply had nothing to say.
Nothing is dropped silently
A frame this module cannot make sense of is logged at :warning and skipped,
not raised on. A malformed frame must not take down a stream that is otherwise
delivering state changes, and a frame that vanishes without trace is the
hardest kind of eventstream bug to find.
Summary
Functions
Decodes a complete buffer of SSE bytes.
Decodes an enumerable of byte chunks, tolerating a split at any offset.
Opens the eventstream and returns a lazy Enumerable of Hue.Event structs.
Functions
@spec decode(binary()) :: [Hue.Event.t()]
Decodes a complete buffer of SSE bytes.
@spec decode_stream(Enumerable.t()) :: [Hue.Event.t()]
Decodes an enumerable of byte chunks, tolerating a split at any offset.
Parser state is carried across chunks, so a frame cut in half by a chunk boundary decodes exactly as it would have whole.
@spec stream( Hue.Client.t(), keyword() ) :: Enumerable.t(Hue.Event.t())
Opens the eventstream and returns a lazy Enumerable of Hue.Event structs.
client
|> Hue.Events.stream()
|> Enum.each(&handle/1)It starts no process of its own
The connection is opened when the stream is first enumerated, in whichever
process enumerates it, and it is closed when the stream stops — including when
the consumer halts early, so Enum.take(stream, 1) releases the socket rather
than leaking it. Where the work runs and what happens after a disconnect are
the caller's decisions; this function reconnects nothing and retries nothing.
Finch's pools and OTP's TLS processes are still there, exactly as they are for
Hue.Resource. What this function adds is nothing of its own.
Nothing is received except the connection's own messages. The caller's mailbox
is a place this library is a guest in, and a bare receive here would take
whatever happened to be at the front of it.
Retry is off, and cannot be turned on
Req retries :safe_transient failures by default, and a GET answered 429 or
5xx is one. That is wrong twice over here. A stream that was partly read
cannot be resumed by repeating the request, and — measured — each abandoned
attempt leaves its own into: :self chunks behind in the caller's mailbox,
because the retry step drops the earlier response without cancelling it. A
consumer whose bridge answered 503 would get the error it expected plus six
stray messages at handle_info.
So this request is made with retry: false regardless of what the client was
built with. Reconnecting is the caller's job, and it is the only place that
knows whether the events it already handled make a fresh request the right
move.
Options
:receive_timeout— milliseconds to wait for the next byte from the bridge, as a non-negative integer or:infinity. Defaults to:infinity; read the moduledoc's "Silence is not evidence of anything" before setting it to a number.
Failures
A stream cannot return {:error, _}, so it raises Hue.Error — on a bridge
that refuses the request (:unauthorized is the one to expect, and it arrives
as HTTP 403 with an HTML body) and on a transport failure while streaming. A
bridge that closes the stream cleanly is not a failure: the enumeration ends.