Skip to main content

lib/circuits_ft232h/gpio.ex

defmodule CircuitsFT232H.GPIO do
  @moduledoc """
  GPIO support for the FT232H's general-purpose pins.

  The FT232H exposes 16 GPIO pins. The labels match the silkscreen on the
  Adafruit FT232H breakout:

  | Label    | Linear pin number | Notes                           |
  | -------- | ----------------- | ------------------------------- |
  | `AD0`    | 0                 | Shared with SCK (SPI) / SCL (I2C) |
  | `AD1`    | 1                 | Shared with MOSI / SDA-out      |
  | `AD2`    | 2                 | Shared with MISO / SDA-in       |
  | `AD3`    | 3                 | Shared with CS (SPI)            |
  | `AD4`-`AD7` | 4..7           | Free                            |
  | `AC0`-`AC7` | 8..15          | Free                            |

  GPIO can run alongside an active I2C or SPI bus on the same chip, provided
  the pin doesn't overlap with the protocol's reserved pins (see the table
  above). The `CircuitsFT232H.Device` GenServer enforces this.

  Public API lives on `CircuitsFT232H.GPIO.Backend` (a `Circuits.GPIO.Backend`
  implementation) and `CircuitsFT232H.GPIO.Handle` (the per-pin handle
  returned by `Circuits.GPIO.open/3`). This module holds shared parsing and
  identifier helpers.
  """

  alias CircuitsFT232H.{Device, USB}
  alias CircuitsFT232H.USB.Descriptor

  @label_regex ~r/^A([DC])([0-7])$/

  @type pin :: Device.pin()

  @typedoc "Combined identifier for one FT232H pin: device id + pin number + label."
  @type pin_ref :: %{controller: Device.id(), pin: pin(), label: String.t()}

  @doc "Returns the label for a linear pin number (`0..15`)."
  @spec label(pin()) :: String.t()
  def label(pin) when pin in 0..7, do: "AD#{pin}"
  def label(pin) when pin in 8..15, do: "AC#{pin - 8}"

  @doc ~S{Parses an `"AD<n>"`/`"AC<n>"` label into a linear pin number.}
  @spec parse_label(String.t()) :: {:ok, pin()} | {:error, :invalid_label}
  def parse_label(label) when is_binary(label) do
    case Regex.run(@label_regex, label) do
      [_, "D", n] -> {:ok, String.to_integer(n)}
      [_, "C", n] -> {:ok, String.to_integer(n) + 8}
      _ -> {:error, :invalid_label}
    end
  end

  @doc """
  Resolves a `Circuits.GPIO.gpio_spec()` into a fully qualified `pin_ref`
  identifying both the chip and the pin within it.

  Accepted forms:
    * `{controller, line_offset}` — e.g. `{"ftdi-3:8", 4}`
    * `{controller, label}` — e.g. `{"ftdi-3:8", "AD4"}`
    * `label` — e.g. `"AD4"`; requires exactly one FT232H attached
    * `line_offset` (integer 0..15) — requires exactly one FT232H attached
  """
  @spec resolve(any()) ::
          {:ok, pin_ref()}
          | {:error, :invalid_label | :invalid_pin | :no_device | :ambiguous_device | term()}
  def resolve({controller, label_or_offset}) when is_binary(controller) do
    with {:ok, pin} <- parse_pin(label_or_offset) do
      {:ok, %{controller: controller, pin: pin, label: label(pin)}}
    end
  end

  def resolve(label_or_offset) do
    with {:ok, pin} <- parse_pin(label_or_offset),
         {:ok, descriptor} <- only_descriptor() do
      {:ok, %{controller: Device.id_for(descriptor), pin: pin, label: label(pin)}}
    end
  end

  @doc """
  Builds a `Circuits.GPIO.identifiers()` map for `pin_ref`.
  """
  @spec identifiers(pin_ref()) :: map()
  def identifiers(%{controller: controller, pin: pin, label: label}) do
    %{location: {controller, pin}, controller: controller, label: label}
  end

  @doc """
  Lists all GPIO pins on every connected FT232H, in canonical order.
  """
  @spec all_pin_refs() :: [pin_ref()]
  def all_pin_refs do
    case USB.list_devices() do
      {:ok, descriptors} ->
        for d <- descriptors,
            controller = Device.id_for(d),
            pin <- 0..15 do
          %{controller: controller, pin: pin, label: label(pin)}
        end

      _ ->
        []
    end
  end

  @doc """
  Looks up the `USB.Descriptor` for the given controller id, or an error if
  no chip with that id is connected.
  """
  @spec find_descriptor(Device.id()) :: {:ok, Descriptor.t()} | {:error, :not_found | term()}
  def find_descriptor(controller) do
    with {:ok, descriptors} <- USB.list_devices() do
      match_controller(descriptors, controller)
    end
  end

  defp match_controller(descriptors, controller) do
    case Enum.find(descriptors, fn d -> Device.id_for(d) == controller end) do
      nil -> {:error, :not_found}
      d -> {:ok, d}
    end
  end

  defp parse_pin(label) when is_binary(label), do: parse_label(label)

  defp parse_pin(pin) when is_integer(pin) and pin in 0..15, do: {:ok, pin}

  defp parse_pin(_), do: {:error, :invalid_pin}

  defp only_descriptor do
    case USB.list_devices() do
      {:ok, [d]} -> {:ok, d}
      {:ok, []} -> {:error, :no_device}
      {:ok, _} -> {:error, :ambiguous_device}
      err -> err
    end
  end
end