Bluez.DeviceCache (bluez v0.2.0)

Copy Markdown View Source

Pure per-device advertisement cache with emit-gating and a bounded size. No I/O — Bluez.Client owns the side effects (it emits the adverts this module returns).

Each device path maps to %{props, last_raw, last_emit, last_seen}. On upsert/4 the new props are merged, the advert is reconstructed (Bluez.Advert), and an advert is returned to emit only when:

  • it's the first sighting, or
  • the reconstructed AD payload changed (sensor data update), or
  • the heartbeat interval has elapsed (keeps RSSI/last-seen fresh in HA without forwarding every PDU).

The heartbeat is configurable per cache instance (new/1's heartbeat_ms: opt) because it's the only refresh some devices ever get: a static-payload beacon (fixed iBeacon UUID/major/minor) changes no AD bytes ever, so without the heartbeat its RSSI/last-seen would freeze at first sight for as long as it keeps advertising.

The map is capped at @max_devices (LRU by last_seen) so a device spraying randomized MACs — each a new BlueZ object path — can't grow it without bound (BlueZ's InterfacesRemoved is the only other prune and is outside our control).

Summary

Functions

The default heartbeat interval (ms) used when new/1 is called without heartbeat_ms:.

Emit decision (pure). Emit on first sight (last_raw == nil), on a payload change, or once the heartbeat interval has elapsed.

An empty cache. heartbeat_ms: (default 10000) sets how long an unchanged advert is suppressed before upsert/4 re-emits it anyway — see the moduledoc. Raises ArgumentError unless it's a positive integer.

Drop path's entry (BlueZ emitted InterfacesRemoved for it).

Distinct devices seen within the last window_ms of monotonic now_ms — the web tab's "devices (15 min)" stat. A plain scan bounded by the LRU cap, so it's cheap enough to run per stats tick.

Number of device entries currently cached.

Merge new_props for path at monotonic now_ms. Returns {cache, adverts} where adverts is [] or a single reconstructed advert the caller should emit.

Types

t()

@type t() :: %Bluez.DeviceCache{
  devices: %{optional(String.t()) => map()},
  heartbeat_ms: pos_integer()
}

Functions

default_heartbeat_ms()

@spec default_heartbeat_ms() :: pos_integer()

The default heartbeat interval (ms) used when new/1 is called without heartbeat_ms:.

emit?(raw, last_raw, last_emit, now, heartbeat_ms)

@spec emit?(binary(), binary() | nil, integer() | nil, integer(), integer()) ::
  boolean()

Emit decision (pure). Emit on first sight (last_raw == nil), on a payload change, or once the heartbeat interval has elapsed.

new(opts \\ [])

@spec new(keyword()) :: t()

An empty cache. heartbeat_ms: (default 10000) sets how long an unchanged advert is suppressed before upsert/4 re-emits it anyway — see the moduledoc. Raises ArgumentError unless it's a positive integer.

remove(cache, path)

@spec remove(t(), String.t()) :: t()

Drop path's entry (BlueZ emitted InterfacesRemoved for it).

seen_within(device_cache, now_ms, window_ms)

@spec seen_within(t(), integer(), pos_integer()) :: non_neg_integer()

Distinct devices seen within the last window_ms of monotonic now_ms — the web tab's "devices (15 min)" stat. A plain scan bounded by the LRU cap, so it's cheap enough to run per stats tick.

Note the cap also bounds the answer: with > 512 active devices the count saturates at the cap (eviction forgets the oldest).

size(device_cache)

@spec size(t()) :: non_neg_integer()

Number of device entries currently cached.

upsert(cache, path, new_props, now_ms)

@spec upsert(t(), String.t(), map(), integer()) :: {t(), [Bluez.Advert.advert()]}

Merge new_props for path at monotonic now_ms. Returns {cache, adverts} where adverts is [] or a single reconstructed advert the caller should emit.