# `Bluez.DeviceCache`
[🔗](https://github.com/bbangert/bluez/blob/v0.3.0/lib/bluez/device_cache.ex#L1)

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

# `t`

```elixir
@type t() :: %Bluez.DeviceCache{
  devices: %{optional(String.t()) =&gt; map()},
  heartbeat_ms: pos_integer()
}
```

# `default_heartbeat_ms`

```elixir
@spec default_heartbeat_ms() :: pos_integer()
```

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

# `emit?`

```elixir
@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`

```elixir
@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`

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

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

# `seen_within`

```elixir
@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`

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

Number of device entries currently cached.

# `upsert`

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
