Hue.Bridge (Hue v0.2.0)

Copy Markdown View Source

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

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

await_write(name, type, rid, body, options \\ [])

@spec await_write(atom(), atom(), String.t(), map(), keyword()) ::
  :ok | {:error, Hue.Error.t()}

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.

child_spec(init_arg)

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

fetch(name \\ Hue.Bridge, type, rid)

@spec fetch(atom(), atom(), String.t()) :: {:ok, map()} | {:error, Hue.Error.t()}

Fetches one resource by rid.

fetch_by_name(name \\ Hue.Bridge, type, target)

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

grouped_light(name \\ Hue.Bridge, type, target)

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

list(name \\ Hue.Bridge, type)

@spec list(atom(), atom()) :: {:ok, [map()]} | {:error, Hue.Error.t()}

Every resource of one type.

name_of(name \\ Hue.Bridge, type, rid)

@spec name_of(atom(), atom(), String.t()) :: String.t() | nil

The name a rid is known by, or nil.

resolve(name \\ Hue.Bridge, type, target)

@spec resolve(atom(), atom(), String.t()) :: {:ok, map()} | {:error, Hue.Error.t()}

Resolves a name-or-rid target to the resource it names.

start_link(options)

@spec start_link(keyword()) :: Supervisor.on_start()

Starts a bridge. See the moduledoc for options.

status(name \\ Hue.Bridge)

@spec status(atom()) :: Hue.Bridge.Cache.status() | :not_started

What the bridge's connection is currently doing.

subscribe(name \\ Hue.Bridge, filter \\ [])

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

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

unsubscribe(name \\ Hue.Bridge, filter \\ [])

@spec unsubscribe(
  atom(),
  keyword()
) :: :ok

Removes a subscription registered with the same filter.

write(name \\ Hue.Bridge, type, rid, body)

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