Skip to main content

lib/air_play/v2/setup.ex

defmodule AirPlay.V2.Setup do
  @moduledoc """
  AirPlay 2 SETUP request helpers.

  This module builds and sends the AP2 binary-plist control-plane requests. It is
  intentionally not a full player yet: HomePod-class devices also require PTP
  timing before encrypted RTP audio will render.
  """

  alias AirPlay.V2.{Pairing, Plist, Ptp, Rtsp2}

  @doc "Send AP2 session SETUP. Defaults to PTP timing, which HomePods require."
  @spec session(Pairing.t(), keyword()) ::
          {:ok, non_neg_integer(), map(), binary(), Pairing.t()} | {:error, term()}
  def session(%Pairing{} = pairing, opts \\ []) do
    timing = Keyword.get(opts, :timing_protocol, "PTP")
    session_uuid = Keyword.get(opts, :session_uuid, pairing.rtsp.session_id)

    body =
      %{
        "deviceID" => Keyword.get(opts, :device_id, random_device_id()),
        "sessionUUID" => session_uuid,
        "timingPort" => Keyword.get(opts, :timing_port, Ptp.event_port()),
        "timingProtocol" => timing
      }
      |> maybe_put_timing_peers(timing, session_uuid, Keyword.get(opts, :local_addresses, []))
      |> Plist.encode!()

    request(pairing, "SETUP", Rtsp2.session_url(pairing.rtsp), body)
  end

  @doc "Send AP2 stream SETUP for 44.1 kHz ALAC RTP."
  @spec stream(Pairing.t(), :inet.port_number(), keyword()) ::
          {:ok, non_neg_integer(), map(), binary(), Pairing.t()} | {:error, term()}
  def stream(%Pairing{} = pairing, control_port, opts \\ []) do
    audio_key = Keyword.get(opts, :audio_key, pairing.audio_key)

    stream =
      %{
        "audioFormat" => Keyword.get(opts, :audio_format, 262_144),
        "audioMode" => Keyword.get(opts, :audio_mode, "default"),
        "ct" => 2,
        "isMedia" => true,
        "latencyMax" => Keyword.get(opts, :latency_max, 88_200),
        "latencyMin" => Keyword.get(opts, :latency_min, 22_050),
        "shk" => {:data, audio_key},
        "spf" => 352,
        "sr" => 44_100,
        "streamConnectionID" => Keyword.get(opts, :stream_connection_id, random_u32()),
        "supportsDynamicStreamID" => Keyword.get(opts, :supports_dynamic_stream_id, true),
        "type" => 96,
        "controlPort" => control_port
      }
      |> maybe_put_data("asc", Keyword.get(opts, :asc))

    body = Plist.encode!(%{"streams" => [stream]})
    request(pairing, "SETUP", Rtsp2.session_url(pairing.rtsp), body)
  end

  @doc "Send AP2 RECORD with the supplied RTP sequence and timestamp."
  @spec record(Pairing.t(), non_neg_integer(), non_neg_integer()) ::
          {:ok, non_neg_integer(), map(), binary(), Pairing.t()} | {:error, term()}
  def record(%Pairing{} = pairing, seq, rtptime) do
    headers = [
      {"X-Apple-ProtocolVersion", "1"},
      {"Range", "npt=0-"},
      {"RTP-Info", "seq=#{seq};rtptime=#{rtptime}"}
    ]

    case Rtsp2.request(pairing.rtsp, "RECORD", Rtsp2.session_url(pairing.rtsp), headers, <<>>) do
      {:ok, status, headers, body, rtsp} -> {:ok, status, headers, body, %{pairing | rtsp: rtsp}}
      error -> error
    end
  end

  @doc "Send AP2 FLUSH with an RTP sequence and timestamp."
  @spec flush(Pairing.t(), non_neg_integer(), non_neg_integer()) ::
          {:ok, non_neg_integer(), map(), binary(), Pairing.t()} | {:error, term()}
  def flush(%Pairing{} = pairing, seq, rtptime) do
    headers = [{"RTP-Info", "seq=#{seq};rtptime=#{rtptime}"}]

    case Rtsp2.request(pairing.rtsp, "FLUSH", Rtsp2.session_url(pairing.rtsp), headers, <<>>) do
      {:ok, status, headers, body, rtsp} -> {:ok, status, headers, body, %{pairing | rtsp: rtsp}}
      error -> error
    end
  end

  @doc "Send AP2 volume as a text/parameters SET_PARAMETER body."
  @spec set_volume(Pairing.t(), number()) ::
          {:ok, non_neg_integer(), map(), binary(), Pairing.t()} | {:error, term()}
  def set_volume(%Pairing{} = pairing, volume) when is_number(volume) do
    db =
      cond do
        volume <= 0 -> -144.0
        volume >= 1 -> 0.0
        true -> 20.0 * :math.log10(volume)
      end

    body = :io_lib.format("volume: ~.2f\r\n", [db]) |> IO.iodata_to_binary()

    case Rtsp2.request(
           pairing.rtsp,
           "SET_PARAMETER",
           Rtsp2.session_url(pairing.rtsp),
           [{"Content-Type", "text/parameters"}],
           body
         ) do
      {:ok, status, headers, body, rtsp} -> {:ok, status, headers, body, %{pairing | rtsp: rtsp}}
      error -> error
    end
  end

  @doc "Send AP2 feedback/keepalive."
  @spec feedback(Pairing.t()) ::
          {:ok, non_neg_integer(), map(), binary(), Pairing.t()} | {:error, term()}
  def feedback(%Pairing{} = pairing) do
    case Rtsp2.request(
           pairing.rtsp,
           "POST",
           Rtsp2.session_url(pairing.rtsp) <> "/feedback",
           [{"Content-Type", "application/x-apple-binary-plist"}],
           <<>>
         ) do
      {:ok, status, headers, body, rtsp} -> {:ok, status, headers, body, %{pairing | rtsp: rtsp}}
      error -> error
    end
  end

  @doc "Send AP2 SETPEERS with the peer IP address list."
  @spec set_peers(Pairing.t(), String.t() | [String.t()]) ::
          {:ok, non_neg_integer(), map(), binary(), Pairing.t()} | {:error, term()}
  def set_peers(%Pairing{} = pairing, local_ip) do
    peers = if is_list(local_ip), do: local_ip, else: [local_ip]
    body = Plist.encode!(peers)

    request(pairing, "SETPEERS", Rtsp2.session_url(pairing.rtsp), body, [
      {"Content-Type", "/peer-list-changed"}
    ])
  end

  @doc "Send AP2 SETRATEANCHORTIME with a prepared anchor plist map."
  @spec set_rate_anchor_time(Pairing.t(), map()) ::
          {:ok, non_neg_integer(), map(), binary(), Pairing.t()} | {:error, term()}
  def set_rate_anchor_time(%Pairing{} = pairing, anchor) when is_map(anchor) do
    body = Plist.encode!(anchor)
    request(pairing, "SETRATEANCHORTIME", Rtsp2.session_url(pairing.rtsp), body)
  end

  @doc "Build and send AP2 SETRATEANCHORTIME from a PTP clock sample."
  @spec set_rate_anchor_time(Pairing.t(), map(), non_neg_integer(), keyword()) ::
          {:ok, non_neg_integer(), map(), binary(), Pairing.t()} | {:error, term()}
  def set_rate_anchor_time(%Pairing{} = pairing, sample, rtp_time, opts \\ [])
      when is_map(sample) do
    set_rate_anchor_time(pairing, Ptp.anchor(sample, rtp_time, opts))
  end

  defp request(pairing, method, path, body, headers \\ []) do
    headers = [{"Content-Type", "application/x-apple-binary-plist"} | headers]

    case Rtsp2.request(pairing.rtsp, method, path, headers, body) do
      {:ok, status, headers, body, rtsp} -> {:ok, status, headers, body, %{pairing | rtsp: rtsp}}
      error -> error
    end
  end

  defp maybe_put_data(map, _key, nil), do: map

  defp maybe_put_data(map, key, value) when is_binary(value),
    do: Map.put(map, key, {:data, value})

  defp maybe_put_timing_peers(body, "PTP", session_uuid, addresses) do
    peer = %{
      "Addresses" => addresses,
      "ID" => session_uuid,
      "SupportsClockPortMatchingOverride" => true
    }

    body
    |> Map.put("timingPeerInfo", peer)
    |> Map.put("timingPeerList", [peer])
  end

  defp maybe_put_timing_peers(body, _timing, _session_uuid, _addresses), do: body

  defp random_device_id do
    :crypto.strong_rand_bytes(6)
    |> :binary.bin_to_list()
    |> Enum.map_join(":", &(&1 |> Integer.to_string(16) |> String.pad_leading(2, "0")))
    |> String.upcase()
  end

  defp random_u32 do
    <<value::32>> = :crypto.strong_rand_bytes(4)
    value
  end
end