A live model of one bridge: an ETS-backed cache seeded by a full fetch and kept current by the eventstream.
It never starts itself
This is a child_spec/1 you place in your own supervision tree, the way
Finch, Redix, and Postgrex are. A library that starts processes on
application boot has decided something that belongs to its consumer.
children = [
{Hue.Bridge, name: MyApp.Hue, client: client}
]Reads bypass the process
fetch/3, list/2, and fetch_by_name/3 are :ets.lookup calls executed in
your process. They do not message the server, do not serialise against
each other, and do not queue behind an eventstream frame being merged. This is
the difference between a cache and a cache-shaped bottleneck.
Writes are the opposite, and deliberately so — see Hue.Light.set/3.
Status
status/1 reports :connecting, :syncing, :live, {:error, reason}, or
:not_started when nothing is running under that name. Note that :live and
readability are different questions: a bridge that synced and then lost its
stream keeps serving its last known state while reporting the error, because
state that is seconds stale beats no state at all.
A stream drop and a server crash are not the same failure, on purpose
Losing the eventstream (Task 8) leaves the cache serving its last known
state, stale but trustworthy — reconnecting is an expected condition on a
home network, not evidence the cached data is wrong. A Hue.Bridge.Server
crash is different: it is a bug, and the ETS table dies with the process
that owned it (the table is not named-and-heir-protected — nothing survives
the crash to keep it alive). The replacement server starts clean and
resyncs, so readers briefly see :not_started rather than the last state a
now-dead process wrote.
That is the correct trade, not an oversight. State written by a process that then crashed is exactly the state you should not keep serving — a crash means something about the sync was already wrong, not merely delayed. An heir process could preserve the table across the crash, but that buys continuity at the price of possibly-corrupt state, in exchange for surviving a case (a crash) that should not happen in the first place. Clean restart plus resync is the honest response; only a stream drop earns "stale beats nothing."
The same crash also loses the outcome of any write already on the wire
when it happens — a different thing from the cached state discussed above,
and lost for a different reason. A write's result arrives at its
Hue.Bridge.Server as a plain {ref, result} message addressed to that
process; a crash replaces it with a new process that knows nothing of the
old one's in-flight tasks, so the message arrives at a pid nothing is
listening on and is silently discarded. A caller that used await: true
sees {:error, %Hue.Error{reason: :timeout}} instead of the write's real
result, and an ordinary subscriber sees nothing at all — no telemetry, no
error event. This is accepted for the same reason the cached state is
discarded rather than preserved: a Hue.Bridge.Server crash is a bug, not
an expected condition, and this library does not spend complexity — an heir
process, a durable write log — insuring an outcome against a case that
should not happen in the first place.
Options
:name— the name this bridge is addressed by. Defaults toHue.Bridge.:client— aHue.ClientfromHue.new/2orHue.from_bridge/2. Required.:retry_after— milliseconds between failed sync attempts. Defaults to 5 seconds.
Summary
Functions
Queues a write and waits for the event that confirms it.
Builds this bridge's child spec, :ided by its :name rather than the
module.
Fetches one resource by rid.
Fetches one resource by the name a user sees in the Hue app.
Resolves a room or zone target to the grouped_light that acts for it.
Every resource of one type.
The name a rid is known by, or nil.
Resolves a name-or-rid target to the resource it names.
Starts a bridge. See the moduledoc for options.
What the bridge's connection is currently doing.
Subscribes the calling process to this bridge's events.
Removes a subscription registered with the same filter.
Queues a write to one resource.
Functions
Queues a write and waits for the event that confirms it.
Waits for the event, not the PUT's response, because the event is the
thing that is true. Returns :ok once an event for that rid arrives,
{:error, %Hue.Error{}} if the write failed, or
{:error, %Hue.Error{reason: :timeout}} if nothing arrived in time.
Two limitations worth knowing
The wait happens in your mailbox. If this process was independently
subscribed to the same rid, this call consumes the confirming event and your
handle_info never sees it. And it unsubscribes afterwards, which clears a
pre-existing rid: subscription for that same rid.
Neither is a problem for the normal case — a script, a test, or a step in a sequence. If a process both subscribes by rid and needs confirmation, use the subscription it already has and match the event yourself.
@spec child_spec(keyword()) :: Supervisor.child_spec()
Builds this bridge's child spec, :ided by its :name rather than the
module.
use Supervisor generates a default child_spec/1 whose :id is
__MODULE__ — fine for a singleton, but multiple bridges are multiple named
children in one consumer's tree, and two children both :ided Hue.Bridge
collide the moment the second one is added. Keying :id on :name instead
is what makes
children = [
{Hue.Bridge, name: MyApp.LivingRoomHue, client: client_a},
{Hue.Bridge, name: MyApp.OfficeHue, client: client_b}
]start both, with no special casing anywhere else in this module.
@spec fetch(atom(), atom(), String.t()) :: {:ok, map()} | {:error, Hue.Error.t()}
Fetches one resource by rid.
@spec fetch_by_name(atom(), atom(), String.t()) :: {:ok, map()} | {:error, Hue.Error.t()}
Fetches one resource by the name a user sees in the Hue app.
@spec grouped_light(atom(), :room | :zone, String.t()) :: {:ok, map()} | {:error, Hue.Error.t()}
Resolves a room or zone target to the grouped_light that acts for it.
A thin delegation to Hue.Bridge.Graph.grouped_light/3, kept beside
resolve/3 rather than left for Hue.Room and Hue.Zone (Task 14) to
reach Graph directly. table/1 is @doc false — an internal seam, not
public API — so anything outside this module that needs the graph walk
would otherwise have to go around that boundary to get it. One entry point
for the public surface; everything else stays private.
@spec list(atom(), atom()) :: {:ok, [map()]} | {:error, Hue.Error.t()}
Every resource of one type.
The name a rid is known by, or nil.
@spec resolve(atom(), atom(), String.t()) :: {:ok, map()} | {:error, Hue.Error.t()}
Resolves a name-or-rid target to the resource it names.
@spec start_link(keyword()) :: Supervisor.on_start()
Starts a bridge. See the moduledoc for options.
@spec status(atom()) :: Hue.Bridge.Cache.status() | :not_started
What the bridge's connection is currently doing.
@spec subscribe( atom(), keyword() ) :: :ok | {:error, Hue.Error.t()}
Subscribes the calling process to this bridge's events.
Delivers {:hue, %Hue.Event{}}. The subscription is removed automatically
when the calling process dies — Registry monitors it — so a LiveView that
crashes leaves nothing behind.
Filters
Hue.Bridge.subscribe(bridge) # everything
Hue.Bridge.subscribe(bridge, type: :button) # just switches
Hue.Bridge.subscribe(bridge, name: "Iris") # one light, by its device's name
Hue.Bridge.subscribe(bridge, rid: rid) # one resource, by identityFiltering happens at the registry rather than in your handle_info. A
process waiting on button presses is not in the dispatch list for a light
event at all, so it is not woken when a scene runs and nineteen lights
change.
Subscribing again with the same filter is a no-op: you get one
subscription, not two. The registry is :duplicate, so without that check a
second subscribe/2 adds a second entry and doubles every matching event
permanently — and a single unsubscribe/2 then removes both, leaving no
way back to single delivery short of unsubscribing and starting again.
The case it protects is one process subscribing more than once: a callback
that re-runs whatever set the subscription up, or several components sharing
a process and each asking for the same events. A process that subscribes and
then dies is not that case — Registry monitors it and removes the entry
regardless.
Subscribing with different filters is a genuinely different subscription each time — an event matching both delivers twice, because that is what you asked for.
A note on names
An event is matched against the name the resource had before the event was applied. For everything except a rename these are the same. For a rename, the event is delivered to subscribers of the old name and subsequent events to the new — which is what a subscriber who asked about "Iris" would expect to see.
Removes a subscription registered with the same filter.
@spec write(atom(), atom(), String.t(), map()) :: :ok | {:error, Hue.Error.t()}
Queues a write to one resource.
Returns :ok as soon as the write is queued, before the bridge has been
asked. That is deliberate on two counts. The PUT's response is not the
truth — the state change arrives as an event a moment later, and blocking
on the response would pretend otherwise. And nothing can be coalesced if
every caller is already waiting on their own request.
Failures surface as [:hue, :write, :failed] telemetry and as an error
event to subscribers, because by then there is no caller to return them to.
Local errors — a capability the light does not have, a malformed option —
are caught before anything is queued, by Hue.Light.set/3 and friends.
Most callers want Hue.Light.set/3, Hue.Room.set/3, or Hue.Zone.set/3.
This is the unwrapped form, for a resource type those do not cover.