lib/osc/message.ex

defmodule OSC.Message do
  @moduledoc """
  A structure representing an OSC message.

  The same structure is used in both directions, serving as both requests and
  replies.  It consists of a request path (encoded as an `OSC.Types.String`), a
  type tag string (same), and an encoded list of arguments that can be decoded
  using the type tag string as a reference.

  For querying device parameters, a typical pattern is that the client will
  send a message with a given `path` and empty `args`, and the server will
  respond via a message with the same `path` and the requested data as the
  `args` list.
  """

  @enforce_keys [:path]
  defstruct(
    path: nil,
    args: []
  )

  alias OSC.Types
  alias __MODULE__

  @typedoc "The `OSC.Message` structure."
  @type t :: %__MODULE__{
          path: path(),
          args: args()
        }

  @typedoc "Path string for an `OSC.Message` structure"
  @type path :: binary

  @typedoc "Arguments list for an `OSC.Message` structure"
  @type args :: Types.args()

  @doc """
  Create a message with a given path and (optional) arguments.

  This is the preferred means of creating `OSC.Message` structures — in
  addition to some basic checks on `path` and `args`, this will also call
  `OSC.Types.validate_args/1` to ensure that all the arguments can be mapped to
  OSC types.
  """
  @spec construct(path(), args()) :: t()
  def construct(path, args \\ []) when is_binary(path) and is_list(args) do
    Types.validate_args(args)
    %Message{path: path, args: args}
  end

  @doc """
  Convert an `OSC.Message` structure to encoded network format.

  The arguments will be encoded using `OSC.Types.encode_args/1`, and then the
  message `path`, type tag string, and encoded arguments will be concatenated
  to form the packet.

  Returns the encoded packet as a binary, ready to send via UDP.
  """
  @spec to_packet(t()) :: binary
  def to_packet(%Message{} = msg) do
    {tag_string, encoded_args} = Types.encode_args(msg.args)

    [
      msg.path |> Types.String.encode(),
      tag_string |> Types.String.encode(),
      encoded_args
    ]
    |> :erlang.iolist_to_binary()
  end

  @doc """
  Parse a raw binary into an `OSC.Message` structure.

  The path and type tag string are decoded using `OSC.Types.String.decode/1`,
  and then the arguments are decoded via `OSC.Types.decode_args/2` using the
  type tag string as a reference.

  Returns the resulting `OSC.Message` structure.  Raises if there is any
  unconsumed data after the message ends.
  """
  @spec parse(binary) :: t()
  def parse(str) do
    {path, rest} = Types.String.decode(str)
    {tag_string, encoded_args} = Types.String.decode(rest)
    {args, ""} = Types.decode_args(tag_string, encoded_args)

    %Message{path: path, args: args}
  end
end