Skip to main content

lib/hamlib/rig.ex

defmodule Hamlib.Rig do
  @moduledoc """
  A process-owned Hamlib rig.

  Wraps a `Hamlib.Nif` handle in a `GenServer` so that:

    * one process owns the serial line (single controlling process — the right
      model for a UART, and what a consuming app's rig-control layer wants),
    * commands are serialized (no two callers driving the rig at once),
    * the port is opened on start and closed/cleaned up on terminate.

  ## Starting

      {:ok, rig} =
        Hamlib.Rig.start_link(
          model: Hamlib.model(:dummy),
          conf: %{}              # config tokens applied before open
        )

  For a real serial rig:

      {:ok, rig} =
        Hamlib.Rig.start_link(
          model: 3073,           # e.g. an Icom CI-V model number
          conf: %{
            "rig_pathname" => "/dev/tty.usbserial-XXXX",
            "serial_speed" => "19200",
            "ptt_type"     => "RTS"
          }
        )

  For NET rigctl (talk to an external `rigctld`):

      {:ok, rig} =
        Hamlib.Rig.start_link(
          model: Hamlib.model(:netrigctl),
          conf: %{"rig_pathname" => "127.0.0.1:4532"}
        )

  ## Android / DigiRig note

  On Android there is no `/dev/tty*` for USB serial; the serial path Hamlib
  expects must be bridged to the Android USB host API. That bridge is tracked
  separately; the API here is identical regardless of how the bytes reach the
  radio.
  """

  use GenServer

  require Logger

  @type option ::
          {:model, integer()}
          | {:conf, %{optional(String.t()) => String.t()}}
          | {:name, GenServer.name()}
          | {:open, boolean()}

  # ── Client API ───────────────────────────────────────────────────────────

  @doc """
  Start a rig process. Options:

    * `:model` (required) — Hamlib model number (see `Hamlib.model/1`).
    * `:conf` — map of Hamlib config tokens applied before opening the port.
    * `:open` — open the port on start (default `true`). `false` leaves the rig
      initialized + configured but closed, to open later with `open/1`.
    * `:name` — optional GenServer name.
  """
  @spec start_link([option()]) :: GenServer.on_start()
  def start_link(opts) do
    {name, opts} = Keyword.pop(opts, :name)
    gen_opts = if name, do: [name: name], else: []
    GenServer.start_link(__MODULE__, opts, gen_opts)
  end

  @doc "Open the rig port (if started with `open: false`, or after `close/1`)."
  @spec open(GenServer.server()) :: :ok | {:error, term()}
  def open(server), do: GenServer.call(server, :open)

  @doc "Close the rig port (rig stays initialized; reopen with `open/1`)."
  @spec close(GenServer.server()) :: :ok | {:error, term()}
  def close(server), do: GenServer.call(server, :close)

  @doc "Set frequency in Hz on the current VFO."
  @spec set_freq(GenServer.server(), number()) :: :ok | {:error, term()}
  def set_freq(server, freq_hz), do: GenServer.call(server, {:set_freq, freq_hz / 1.0})

  @doc "Get frequency in Hz from the current VFO. `{:ok, hz}`."
  @spec get_freq(GenServer.server()) :: {:ok, float()} | {:error, term()}
  def get_freq(server), do: GenServer.call(server, :get_freq)

  @doc "Set mode by string (\"USB\", \"PKTUSB\", …) and passband Hz (0 = normal)."
  @spec set_mode(GenServer.server(), String.t(), integer()) :: :ok | {:error, term()}
  def set_mode(server, mode, passband_hz \\ 0),
    do: GenServer.call(server, {:set_mode, mode, passband_hz})

  @doc "Get current `{mode_string, passband_hz}`."
  @spec get_mode(GenServer.server()) :: {:ok, {String.t(), integer()}} | {:error, term()}
  def get_mode(server), do: GenServer.call(server, :get_mode)

  @doc "Key/unkey the transmitter. `true` = TX, `false` = RX."
  @spec set_ptt(GenServer.server(), boolean()) :: :ok | {:error, term()}
  def set_ptt(server, on) when is_boolean(on), do: GenServer.call(server, {:set_ptt, on})

  @doc "Get PTT state as a boolean. `{:ok, true|false}`."
  @spec get_ptt(GenServer.server()) :: {:ok, boolean()} | {:error, term()}
  def get_ptt(server), do: GenServer.call(server, :get_ptt)

  # ── Server ───────────────────────────────────────────────────────────────

  @impl true
  def init(opts) do
    model = Keyword.fetch!(opts, :model)
    conf = Keyword.get(opts, :conf, %{})
    do_open = Keyword.get(opts, :open, true)

    with {:ok, handle} <- Hamlib.Nif.init(model),
         :ok <- apply_conf(handle, conf),
         :ok <- maybe_open(handle, do_open) do
      {:ok, %{handle: handle, model: model, conf: conf, open?: do_open}}
    else
      {:error, reason} ->
        {:stop, {:hamlib_init_failed, reason}}
    end
  end

  @impl true
  def handle_call(:open, _from, %{open?: true} = state) do
    {:reply, :ok, state}
  end

  def handle_call(:open, _from, state) do
    case Hamlib.Nif.open(state.handle) do
      :ok -> {:reply, :ok, %{state | open?: true}}
      err -> {:reply, err, state}
    end
  end

  def handle_call(:close, _from, %{open?: false} = state) do
    {:reply, :ok, state}
  end

  def handle_call(:close, _from, state) do
    case Hamlib.Nif.close(state.handle) do
      :ok -> {:reply, :ok, %{state | open?: false}}
      err -> {:reply, err, state}
    end
  end

  def handle_call({:set_freq, freq_hz}, _from, state),
    do: {:reply, Hamlib.Nif.set_freq(state.handle, freq_hz), state}

  def handle_call(:get_freq, _from, state),
    do: {:reply, Hamlib.Nif.get_freq(state.handle), state}

  def handle_call({:set_mode, mode, pb}, _from, state),
    do: {:reply, Hamlib.Nif.set_mode(state.handle, mode, pb), state}

  def handle_call(:get_mode, _from, state),
    do: {:reply, Hamlib.Nif.get_mode(state.handle), state}

  def handle_call({:set_ptt, on}, _from, state),
    do: {:reply, Hamlib.Nif.set_ptt(state.handle, on), state}

  def handle_call(:get_ptt, _from, state),
    do: {:reply, Hamlib.Nif.get_ptt(state.handle), state}

  @impl true
  def terminate(_reason, %{handle: handle, open?: open?}) do
    # The handle's Drop also closes+cleans up, but be explicit and prompt so the
    # transmitter is de-keyed and the port released immediately on shutdown.
    if open?, do: Hamlib.Nif.close(handle)
    :ok
  end

  def terminate(_reason, _state), do: :ok

  # ── Helpers ──────────────────────────────────────────────────────────────

  defp apply_conf(handle, conf) do
    Enum.reduce_while(conf, :ok, fn {token, value}, :ok ->
      case Hamlib.Nif.set_conf(handle, to_string(token), to_string(value)) do
        :ok -> {:cont, :ok}
        err -> {:halt, err}
      end
    end)
  end

  defp maybe_open(_handle, false), do: :ok
  defp maybe_open(handle, true), do: Hamlib.Nif.open(handle)
end