defmodule Raxol.Sensor.Feed do @moduledoc """ GenServer managing a single sensor's polling lifecycle. Connects to a sensor module, polls at the configured sample rate, buffers readings in a CircularBuffer, and forwards each reading to the fusion process. """ use GenServer require Logger @default_buffer_size 1000 @default_max_errors 10 @backoff_ms 5_000 @type status :: :connecting | :running | :error | :stopped @type t :: %__MODULE__{ sensor_id: atom(), module: module(), sensor_state: term(), sample_rate_ms: pos_integer(), buffer: CircularBuffer.t(), buffer_size: pos_integer(), status: status(), fusion_pid: pid() | nil, connect_opts: keyword(), error_count: non_neg_integer(), max_errors: pos_integer(), poll_ref: reference() | nil, backoff_ref: reference() | nil } defstruct sensor_id: nil, module: nil, sensor_state: nil, sample_rate_ms: 100, buffer: nil, buffer_size: @default_buffer_size, status: :connecting, fusion_pid: nil, connect_opts: [], error_count: 0, max_errors: @default_max_errors, poll_ref: nil, backoff_ref: nil # -- Public API -- @spec start_link(keyword()) :: GenServer.on_start() def start_link(opts) do {name, opts} = Keyword.pop(opts, :name) GenServer.start_link(__MODULE__, opts, name: name) end @spec get_latest(GenServer.server()) :: {:ok, map()} | {:error, :empty} def get_latest(server) do GenServer.call(server, :get_latest) end @spec get_history(GenServer.server(), pos_integer()) :: [map()] def get_history(server, count \\ 10) do GenServer.call(server, {:get_history, count}) end @spec get_status(GenServer.server()) :: status() def get_status(server) do GenServer.call(server, :get_status) end @spec reconnect(GenServer.server()) :: :ok def reconnect(server) do GenServer.cast(server, :reconnect) end # -- Callbacks -- @impl true def init(opts) do sensor_id = Keyword.fetch!(opts, :sensor_id) module = Keyword.fetch!(opts, :module) fusion_pid = Keyword.get(opts, :fusion_pid) buffer_size = Keyword.get(opts, :buffer_size, @default_buffer_size) max_errors = Keyword.get(opts, :max_errors, @default_max_errors) sample_rate = Keyword.get(opts, :sample_rate_ms, 100) connect_opts = Keyword.get(opts, :connect_opts, []) state = %__MODULE__{ sensor_id: sensor_id, module: module, fusion_pid: fusion_pid, buffer_size: buffer_size, buffer: CircularBuffer.new(buffer_size), max_errors: max_errors, sample_rate_ms: sample_rate, connect_opts: connect_opts } {:ok, state, {:continue, :connect}} end @impl true def handle_continue(:connect, %__MODULE__{} = state) do case state.module.connect( [sensor_id: state.sensor_id] ++ state.connect_opts ) do {:ok, sensor_state} -> state = %__MODULE__{ state | sensor_state: sensor_state, status: :running, error_count: 0 } {:noreply, schedule_poll(state)} {:error, reason} -> Logger.warning( "Sensor #{state.sensor_id} connect failed: #{inspect(reason)}" ) state = %__MODULE__{state | status: :error} {:noreply, schedule_backoff(state)} end end @impl true def handle_call(:get_latest, _from, %__MODULE__{} = state) do case Enum.take(state.buffer, 1) do [reading] -> {:reply, {:ok, reading}, state} [] -> {:reply, {:error, :empty}, state} end end @impl true def handle_call({:get_history, count}, _from, %__MODULE__{} = state) do {:reply, Enum.take(state.buffer, count), state} end @impl true def handle_call(:get_status, _from, %__MODULE__{} = state) do {:reply, state.status, state} end @impl true def handle_info(:poll, %__MODULE__{status: :running} = state) do case state.module.read(state.sensor_state) do {:ok, reading, new_sensor_state} -> buffer = CircularBuffer.insert(state.buffer, reading) notify_fusion(state.fusion_pid, reading) state = %__MODULE__{ state | sensor_state: new_sensor_state, buffer: buffer, error_count: 0 } {:noreply, schedule_poll(state)} {:error, reason} -> error_count = state.error_count + 1 Logger.warning( "Sensor #{state.sensor_id} read error (#{error_count}/#{state.max_errors}): #{inspect(reason)}" ) if error_count >= state.max_errors do disconnect_sensor(state.module, state.sensor_state) state = %__MODULE__{ state | error_count: error_count, status: :error, sensor_state: nil } {:noreply, schedule_backoff(state)} else state = %__MODULE__{state | error_count: error_count} {:noreply, schedule_poll(state)} end end end @impl true def handle_info(:poll, %__MODULE__{} = state) do {:noreply, state} end @impl true def handle_info(:backoff_reconnect, %__MODULE__{} = state) do {:noreply, %__MODULE__{state | backoff_ref: nil}, {:continue, :connect}} end @impl true def handle_info(msg, state) do Logger.debug("#{__MODULE__} received unexpected message: #{inspect(msg)}") {:noreply, state} end @impl true def handle_cast(:reconnect, %__MODULE__{} = state) do state = cancel_timers(state) disconnect_sensor(state.module, state.sensor_state) state = %__MODULE__{ state | sensor_state: nil, status: :connecting, error_count: 0 } {:noreply, state, {:continue, :connect}} end # -- Private -- defp notify_fusion(nil, _reading), do: :ok defp notify_fusion(pid, reading), do: send(pid, {:sensor_reading, reading}) defp disconnect_sensor(_module, nil), do: :ok defp disconnect_sensor(module, sensor_state), do: module.disconnect(sensor_state) defp schedule_poll(%__MODULE__{} = state) do _ = cancel_timer(state.poll_ref) ref = Process.send_after(self(), :poll, state.sample_rate_ms) %__MODULE__{state | poll_ref: ref} end defp schedule_backoff(%__MODULE__{} = state) do _ = cancel_timer(state.backoff_ref) ref = Process.send_after(self(), :backoff_reconnect, @backoff_ms) %__MODULE__{state | backoff_ref: ref} end defp cancel_timers(%__MODULE__{} = state) do _ = cancel_timer(state.poll_ref) _ = cancel_timer(state.backoff_ref) %__MODULE__{state | poll_ref: nil, backoff_ref: nil} end defp cancel_timer(nil), do: :ok defp cancel_timer(ref), do: Process.cancel_timer(ref) end