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