Translates set options into a CLIP v2 request body, checking capabilities
against the cached resource first.
Two kinds of wrongness
brightness: "loud" is a bug in the calling code. There is no runtime
handling for it — only a source change — so it raises, at the call
site, with a message naming the option. brightness: 150 is the same kind
of bug: CLIP v2's dimming.brightness is a percentage, 0-100 inclusive,
and a value outside that range was never valid input, on any light,
regardless of what is plugged into the socket — so it raises too,
rather than being treated as a per-light capability question. This module
deliberately does not validate against a light's min_dim_level: that
is a physical floor that varies by bulb, and clamping a legal percentage up
to it is the bridge's job, not this library's — see translate/2's
:brightness clause.
color: "#ff8800" sent to a white-only bulb is not a bug. It is a mismatch
between what the code asked for and what is screwed into a lamp in the
user's house, which the code could not have known, so it returns
{:error, %Hue.Error{reason: :not_color_capable}}.
Every option is validated before any option is translated
build/2 runs in two passes rather than one. The first, validate_option!/1,
walks every option and checks its value — no resource, no capability, no
I/O — and raises on the first malformed one it finds. Only once every option
in the call has passed does the second pass, translate/2, touch the
resource at all.
The split exists because a single Enum.reduce_while/3 pass that validated
and translated together let the wrong kind of wrongness win by accident.
Body.build([color: "#ff8800", brightness: "loud"], a_colourless_light)
under that shape resolved :color first, found a genuine capability
mismatch, and reduce_while halted there — so brightness: "loud", a bug
in the caller's own source, was never reached and never raised. The bug got
reported as "this light has no colour support," which sends whoever reads
that message to look at their light fixture instead of their code. Splitting
validation out means a malformed option raises regardless of the order
options were given in and regardless of what any other option's capability
check would have found — the property the moduledoc always claimed but
the single-pass version did not actually have.
Capabilities are checked before the request leaves
Because Hue.Bridge caches every light's capabilities, this check does not
need the bridge's opinion. That is strictly better than sending the request
and interpreting the rejection: no round trip, and an error that names the
light rather than quoting a CLIP description. The reference bridge has two
lights with no dimming key at all, so :not_dimmable is a case real
users hit, not a defensive branch. Option order still decides which
capability error is reported when two options each fail on a different
capability — that is a caller with two real problems, and reporting the
first one honestly is not a bug the way the paragraph above was.
Colour and colour temperature mostly delegate rather than duplicate
Hue.Color.payload/2 and Hue.Color.mirek_for/2 return
{:error, %Hue.Error{reason: :not_color_capable, rid: ...}} for a light
with no "color" or no "color_temperature" key — confirmed against
layer 1 directly before writing this module — so translate/2's :color
and :kelvin clauses do not check capability themselves; a second check
here would be a second place for the two to drift.
Hue.Color.to_xy/2 (which payload/2 calls) validates its input before it
ever consults the light's gamut, so a value that could never be a colour
under any of its accepted input shapes (see Hue.Color.input/0) raises
there regardless of what is plugged into the socket. validate_option!/1
rejects the same shapes itself, earlier and with a message that names the
option — a bare number, an atom, a string that is not a hex code, a tuple
of the wrong arity or element types — before translate/2 ever runs. What
it does not attempt is hex-digit correctness or an RGB component's
0..255 range: those are shapes validate_option!/1 accepts as
well-formed (any "#"-prefixed string, any integer-tupled {r, g, b}),
and only Hue.Color.to_xy/2 inspects their content, raising
(Color.InvalidHexError, Color.InvalidComponentError) once a colour-
capable light actually reaches them. Against a colourless light, a value
well-formed enough to survive both checks — correct hex digits, RGB
components in range — still reports as :not_color_capable, which is the
one case that genuinely is a capability mismatch rather than a caller bug.
Summary
Functions
Builds the request body for options against resource.
Functions
@spec build( keyword(), map() ) :: {:ok, map()} | {:error, Hue.Error.t()}
Builds the request body for options against resource.
Returns {:ok, body}, or {:error, %Hue.Error{}} for a capability the
resource does not have. Raises ArgumentError for an unknown option or a
malformed value — always, regardless of where in options it appears and
regardless of what any other option's capability check would have found.
See the moduledoc's "Every option is validated before any option is
translated".