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