Hue.Events (Hue v0.2.0)

Copy Markdown View Source

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_events is 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

decode(binary)

@spec decode(binary()) :: [Hue.Event.t()]

Decodes a complete buffer of SSE bytes.

decode_stream(chunks)

@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.

stream(client, options \\ [])

@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.