Packages

BlueZ-over-D-Bus client for Elixir: BLE scanning, GATT, pairing, and bluez-alsa audio

Current section

Files

Jump to
bluez lib bluez device_cache.ex
Raw

lib/bluez/device_cache.ex

defmodule Bluez.DeviceCache do
@moduledoc """
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 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).
"""
alias Bluez.Advert
@max_devices 512
@rssi_heartbeat_ms 10_000
@type t :: %__MODULE__{devices: %{optional(String.t()) => map()}}
defstruct devices: %{}
@doc "An empty cache."
@spec new() :: t()
def new, do: %__MODULE__{}
@doc "Number of device entries currently cached."
@spec size(t()) :: non_neg_integer()
def size(%__MODULE__{devices: devices}), do: map_size(devices)
@doc """
Merge `new_props` for `path` at monotonic `now_ms`. Returns
`{cache, adverts}` where `adverts` is `[]` or a single reconstructed advert
the caller should emit.
"""
@spec upsert(t(), String.t(), map(), integer()) ::
{t(), [Bluez.Advert.advert()]}
def upsert(%__MODULE__{} = cache, path, new_props, now_ms) do
entry = Map.get(cache.devices, path, %{props: %{}, last_raw: nil, last_emit: nil})
merged = Map.merge(entry.props, new_props)
{adverts, last_raw, last_emit} =
case Advert.reconstruct(merged) do
{:ok, advert} ->
if emit?(advert.raw_data, entry.last_raw, entry.last_emit, now_ms, @rssi_heartbeat_ms) do
{[advert], advert.raw_data, now_ms}
else
{[], entry.last_raw, entry.last_emit}
end
:skip ->
{[], entry.last_raw, entry.last_emit}
end
new_entry = %{props: merged, last_raw: last_raw, last_emit: last_emit, last_seen: now_ms}
devices = cap(Map.put(cache.devices, path, new_entry))
{%{cache | devices: devices}, adverts}
end
@doc "Drop `path`'s entry (BlueZ emitted `InterfacesRemoved` for it)."
@spec remove(t(), String.t()) :: t()
def remove(%__MODULE__{} = cache, path), do: %{cache | devices: Map.delete(cache.devices, path)}
@doc """
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 > `#{@max_devices}` active
devices the count saturates at the cap (eviction forgets the oldest).
"""
@spec seen_within(t(), integer(), pos_integer()) :: non_neg_integer()
def seen_within(%__MODULE__{devices: devices}, now_ms, window_ms) do
Enum.count(devices, fn {_path, entry} -> now_ms - entry.last_seen <= window_ms end)
end
@doc """
Emit decision (pure). Emit on first sight (`last_raw == nil`), on a payload
change, or once the heartbeat interval has elapsed.
"""
@spec emit?(binary(), binary() | nil, integer() | nil, integer(), integer()) :: boolean()
def emit?(_raw, nil, _last_emit, _now, _heartbeat_ms), do: true
def emit?(raw, last_raw, _last_emit, _now, _heartbeat_ms) when raw != last_raw, do: true
# No prior emit time — treat as never emitted (also keeps the function total:
# avoids `now - nil` when called with last_raw present but last_emit nil).
def emit?(_raw, _last_raw, nil, _now, _heartbeat_ms), do: true
def emit?(_raw, _last_raw, last_emit, now, heartbeat_ms), do: now - last_emit >= heartbeat_ms
# Evict the least-recently-seen entries until at or below the cap. The
# just-upserted entry has the newest last_seen, so it's never the victim.
defp cap(devices) when map_size(devices) <= @max_devices, do: devices
defp cap(devices) do
{oldest, _} = Enum.min_by(devices, fn {_path, e} -> e.last_seen end)
cap(Map.delete(devices, oldest))
end
end