A normalised failure from a Hue bridge.
Three different wire formats end up here, and a client that assumes one of them breaks on the others:
- CLIP v2 puts the signal in the HTTP status and the detail in
{"errors":[{"description": …}]}. There are no numeric error codes — reasons are derived from status, never from the prose. - CLIP v2 auth failure returns HTTP 403 with a
text/htmlbody. Decoding that as JSON crashes on the single most likely first-run failure. - v1 pairing returns HTTP 200 carrying
[{"error":{"type":101, …}}]— application errors at 200, with numeric types.
Match on :reason. It is stable across all three.
Reasons owned by layer 2
Most reasons above come from the bridge or the transport. Two are raised
locally by Hue.Bridge, layer 2's live model, and never by anything in this
module:
:not_started— noHue.Bridgeis running under the name you addressed. You asked a live model that does not exist; add itschild_spec/1to your supervision tree.:not_synced— the bridge is running but has not completed its first full fetch, so the cache cannot answer yet. Transient;Hue.Bridge.status/1reports:connectingor:syncingwhile it lasts.
What this struct does and does not mean
An Error means something outside the process refused the call — the bridge or
the transport — or that a locally-known capability makes the call impossible.
It is never used for an argument that could not have been valid: brightness: "loud" is a caller bug and raises.
The reason worth handling explicitly is :unauthorized. It means no valid
application key was sent. Get one with Hue.Pairing.pair/2 after pressing the
round link button on the bridge.
Summary
Functions
Builds an error from the v1 pairing envelope, which arrives at HTTP 200.
Builds an error from a CLIP v2 response.
Builds an error from whatever Req handed back in an {:error, _}.
Builds an error for a failure below the application layer.
Types
@type reason() ::
:unauthorized
| :link_button_not_pressed
| :not_found
| :rate_limited
| :bridge_busy
| :unsupported_bridge
| :certificate_changed
| :bridge_identity_mismatch
| :unexpected_verification_event
| :not_dimmable
| :not_color_capable
| :invalid_gamut
| :no_grouped_light
| :not_started
| :not_synced
| :timeout
| :econnrefused
| :closed
| :nxdomain
| :unexpected_response
| :unknown
@type t() :: %Hue.Error{ __exception__: true, description: String.t() | nil, reason: reason(), rid: String.t() | nil, status: pos_integer() | nil, type: integer() | nil }
Functions
Builds an error from the v1 pairing envelope, which arrives at HTTP 200.
Requires the body to be a list whose first element is
%{"error" => %{"type" => integer}} — not merely a list with an "error"
key present. An "error" value with no "type", a non-map "error"
value, an empty list, or a body that is not a list at all all raise
FunctionClauseError. Checking for the presence of an "error" key is
necessary but not sufficient before calling this; callers must confirm the
full shape, not just route on "error" versus "success".
@spec from_response(pos_integer(), binary(), String.t() | nil, keyword()) :: t()
Builds an error from a CLIP v2 response.
content_type matters: the bridge answers an unauthenticated request with an
HTML page, so the body is only parsed when it claims to be JSON.
@spec from_transport(Exception.t() | %{reason: term()}) :: t()
Builds an error from whatever Req handed back in an {:error, _}.
Every request path in this library goes through here, because the reason Req carries is not always an atom, and the one case where it is not is the most security-relevant failure the library can report.
Why the pin needs unwrapping
When Hue.Transport.verify_pinned/4 refuses a certificate it returns
{:fail, :certificate_changed}. :ssl turns that into a fatal alert, and
Mint surfaces it as %Mint.TransportError{reason: {:tls_alert, {:handshake_failure, text}}} — a tuple. Handed to transport/2 that
raises FunctionClauseError; guarded away with is_atom it degrades to
:unknown. Either way the one failure Hue.Transport documents as "your
bridge was replaced or you are being intercepted" never reaches a caller in
a form they can match on.
OTP appends the refused term to the alert text on a line of its own, so the
original reason is recoverable, and the two terms this library's verify_fun
can fail with are mapped back to themselves.
Why the match is narrow
Reporting a benign failure as an interception is the same class of mistake as
reporting a differently-formatted fingerprint as one. So the trailing line
must equal one of those terms rather than merely contain it, and every
other alert stays :unknown with its text intact. A server-sent
protocol_version or insufficient_security, and a handshake_failure
carrying any other term — hostname_check_failed and cert_expired both
arrive through this same wrapping — are negotiation or certificate problems,
not evidence that anything was swapped.
Why it tolerates two spellings of that line
ssl_alert.erl carries two adjacent formatters for the same alert:
own_alert_format_depth/1 renders " ~s\n ~P", putting the refused term
bare on the last line, while own_alert_format/1 ten lines above renders
" ~s\n - ~p", which inspects the atom and so prints - :certificate_changed.
Which one produced the text handed to this function is not ours to choose, and
a parser coupled to one of them degrades the other to :unknown. So a leading
- and a leading : are stripped, and trailing blank lines are skipped,
before an equality comparison that is otherwise unchanged.
Builds an error for a failure below the application layer.