diff --git a/lib/device.ex b/lib/device.ex
index c8280d0..60049e5 100644
--- a/lib/device.ex
+++ b/lib/device.ex
@@ -18,6 +18,8 @@ defmodule Onvif.Device do
:ntp,
:media_ver10_service_path,
:media_ver20_service_path,
+ :recording_service_path,
+ :replay_service_path,
:auth_type,
:time_diff_from_system_secs,
:port,
@@ -41,6 +43,8 @@ defmodule Onvif.Device do
field(:ntp, :string)
field(:media_ver10_service_path, :string)
field(:media_ver20_service_path, :string)
+ field(:recording_service_path, :string)
+ field(:replay_service_path, :string)
embeds_one(:system_date_time, Onvif.Devices.SystemDateAndTime)
embeds_many(:services, Onvif.Device.Service)
@@ -288,6 +292,8 @@ defmodule Onvif.Device do
device
|> Map.put(:media_ver10_service_path, get_media_ver10_service_path(device.services))
|> Map.put(:media_ver20_service_path, get_media_ver20_service_path(device.services))
+ |> Map.put(:recording_service_path, get_recoding_service_path(device.services))
+ |> Map.put(:replay_service_path, get_replay_service_path(device.services))
end
defp get_media_ver20_service_path(services) do
@@ -303,4 +309,18 @@ defmodule Onvif.Device do
%Onvif.Device.Service{} = service -> service.xaddr |> URI.parse() |> Map.get(:path)
end
end
+
+ defp get_recoding_service_path(services) do
+ case Enum.find(services, &String.contains?(&1.namespace, "/recording")) do
+ nil -> nil
+ %Onvif.Device.Service{} = service -> service.xaddr |> URI.parse() |> Map.get(:path)
+ end
+ end
+
+ defp get_replay_service_path(services) do
+ case Enum.find(services, &String.contains?(&1.namespace, "/replay")) do
+ nil -> nil
+ %Onvif.Device.Service{} = service -> service.xaddr |> URI.parse() |> Map.get(:path)
+ end
+ end
end
diff --git a/lib/factory.ex b/lib/factory.ex
index 50545b8..2d6fd54 100644
--- a/lib/factory.ex
+++ b/lib/factory.ex
@@ -9,6 +9,8 @@ defmodule Onvif.Factory do
manufacturer: "General",
media_ver10_service_path: "/onvif/media_service",
media_ver20_service_path: "/onvif/media2_service",
+ replay_service_path: "/onvif/replay_service",
+ recording_service_path: "/onvif/recording_service",
model: "N864A6",
ntp: "NTP",
password: "admin",
diff --git a/lib/recording.ex b/lib/recording.ex
new file mode 100644
index 0000000..8f6cfb2
--- /dev/null
+++ b/lib/recording.ex
@@ -0,0 +1,56 @@
+defmodule Onvif.Recording do
+ @moduledoc """
+ Interface for making requests to the Onvif recording service
+ http://www.onvif.org/onvif/ver10/recording.wsdl
+ """
+ require Logger
+ alias Onvif.Device
+
+ @namespaces [
+ "xmlns:trc": "http://www.onvif.org/ver10/recording/wsdl",
+ "xmlns:tt": "http://www.onvif.org/ver10/schema"
+ ]
+
+ @spec request(Device.t(), module()) :: {:ok, any} | {:error, map()}
+ def request(%Device{} = device, operation) do
+ content = generate_content(operation)
+ do_request(device, operation, content)
+ end
+
+ def request(%Device{} = device, args, operation) do
+ content = generate_content(operation, args)
+ do_request(device, operation, content)
+ end
+
+ defp do_request(device, operation, content) do
+ device
+ |> Onvif.API.client(service_path: :recording_service_path)
+ |> Tesla.request(
+ method: :post,
+ headers: [{"Content-Type", "application/soap+xml"}, {"SOAPAction", operation.soap_action()}],
+ body: %Onvif.Request{content: content, namespaces: @namespaces}
+ )
+ |> parse_response(operation)
+ end
+
+ defp generate_content(operation), do: operation.request_body()
+ defp generate_content(operation, args), do: operation.request_body(args)
+
+ defp parse_response({:ok, %{status: 200, body: body}}, operation) do
+ operation.response(body)
+ end
+
+ defp parse_response({:ok, %{status: status_code, body: body}}, operation)
+ when status_code >= 400,
+ do:
+ {:error,
+ %{
+ status: status_code,
+ reason: "Received #{status_code} from #{operation}",
+ response: body
+ }}
+
+ defp parse_response({:error, response}, operation) do
+ {:error, %{status: nil, reason: "Error performing #{operation}", response: response}}
+ end
+end
diff --git a/lib/recording/create_recording.ex b/lib/recording/create_recording.ex
new file mode 100644
index 0000000..2d1a26f
--- /dev/null
+++ b/lib/recording/create_recording.ex
@@ -0,0 +1,71 @@
+defmodule Onvif.Recording.CreateRecording do
+ import SweetXml
+ import XmlBuilder
+ require Logger
+
+ def soap_action, do: "http://www.onvif.org/ver10/recording/wsdl/CreateRecording"
+
+ def request(device, args) do
+ Onvif.Recording.request(device, args, __MODULE__)
+ end
+
+ def request_body(config: %Onvif.Recording.Recordings.Configuration{} = config) do
+ element(:"s:Body", [
+ element(:"trc:CreateRecording", [
+ element(:"trc:RecordingConfiguration", [
+ element(:"tt:Source", [
+ gen_source_id(config.source.source_id),
+ gen_name(config.source.name),
+ gen_location(config.source.location),
+ gen_description(config.source.description),
+ gen_address(config.source.address)
+ ]),
+ gen_content(config.content),
+ gen_maximum_retention_time(config.maximum_retention_time)
+ ])
+ ])
+ ])
+ end
+
+ def gen_source_id(nil), do: []
+ def gen_source_id(""), do: []
+ def gen_source_id(source_id), do: element(:"tt:SourceId", source_id)
+
+ def gen_name(nil), do: []
+ def gen_name(""), do: []
+ def gen_name(name), do: element(:"tt:Name", name)
+
+ def gen_location(nil), do: []
+ def gen_location(""), do: []
+ def gen_location(location), do: element(:"tt:Location", location)
+
+ def gen_description(nil), do: []
+ def gen_description(""), do: []
+ def gen_description(description), do: element(:"tt:Description", description)
+
+ def gen_address(nil), do: []
+ def gen_address(""), do: []
+ def gen_address(address), do: element(:"tt:Address", address)
+
+ def gen_content(nil), do: []
+ def gen_content(""), do: []
+ def gen_content(content), do: element(:"tt:Content", content)
+
+ def gen_maximum_retention_time(nil), do: []
+ def gen_maximum_retention_time(""), do: []
+ def gen_maximum_retention_time(maximum_retention_time), do: element(:"tt:MaximumRetentionTime", maximum_retention_time)
+
+
+ def response(xml_response_body) do
+ recording_token =
+ xml_response_body
+ |> parse(namespace_conformant: true, quiet: true)
+ |> xpath(
+ ~x"//s:Envelope/s:Body/trc:CreateRecordingResponse/trc:RecordingToken/text()"s
+ |> add_namespace("s", "http://www.w3.org/2003/05/soap-envelope")
+ |> add_namespace("trc", "http://www.onvif.org/ver10/recording/wsdl")
+ )
+
+ {:ok, recording_token}
+ end
+end
diff --git a/lib/recording/create_recording_job.ex b/lib/recording/create_recording_job.ex
new file mode 100644
index 0000000..da1ca52
--- /dev/null
+++ b/lib/recording/create_recording_job.ex
@@ -0,0 +1,36 @@
+defmodule Onvif.Recording.CreateRecordingJob do
+ import SweetXml
+ import XmlBuilder
+ require Logger
+
+ def soap_action, do: "http://www.onvif.org/ver10/recording/wsdl/CreateRecordingJob"
+
+ def request(device, args) do
+ Onvif.Recording.request(device, args, __MODULE__)
+ end
+
+ def request_body(recording_token, priority \\ "0", mode \\ "Active") do
+ element(:"s:Body", [
+ element(:"trc:CreateRecordingJob", [
+ element(:"trc:JobConfiguration", [
+ element(:"tt:RecordingToken", recording_token),
+ element(:"tt:Mode", mode),
+ element(:"tt:Priority", priority)
+ ])
+ ])
+ ])
+ end
+
+ def response(xml_response_body) do
+ parsed_result =
+ xml_response_body
+ |> parse(namespace_conformant: true, quiet: true)
+ |> xpath(
+ ~x"//s:Envelope/s:Body/trc:CreateRecordingJobResponse/trc:JobToken/text()"s0
+ |> add_namespace("s", "http://www.w3.org/2003/05/soap-envelope")
+ |> add_namespace("trc", "http://www.onvif.org/ver10/recording/wsdl")
+ |> add_namespace("tt", "http://www.onvif.org/ver10/schema")
+ )
+ {:ok, parsed_result}
+ end
+end
diff --git a/lib/recording/get_recording_jobs.ex b/lib/recording/get_recording_jobs.ex
new file mode 100644
index 0000000..d271e3b
--- /dev/null
+++ b/lib/recording/get_recording_jobs.ex
@@ -0,0 +1,44 @@
+defmodule Onvif.Recording.GetRecordingJobs do
+ import SweetXml
+ import XmlBuilder
+ require Logger
+
+ alias Onvif.Recording.RecordingJob
+
+ def soap_action, do: "http://www.onvif.org/ver10/recording/wsdl/GetRecordingJobs"
+
+ def request(device) do
+ Onvif.Recording.request(device, __MODULE__)
+ end
+
+ def request_body() do
+ element(:"s:Body", [
+ element(:"trc:GetRecordingJobs")
+ ])
+ end
+
+ def response(xml_response_body) do
+ response =
+ xml_response_body
+ |> parse(namespace_conformant: true, quiet: true)
+ |> xpath(
+ ~x"//s:Envelope/s:Body/trc:GetRecordingJobsResponse/trc:JobItem"el
+ |> add_namespace("s", "http://www.w3.org/2003/05/soap-envelope")
+ |> add_namespace("trc", "http://www.onvif.org/ver10/recording/wsdl")
+ |> add_namespace("tt", "http://www.onvif.org/ver10/schema")
+ )
+ |> Enum.map(&RecordingJob.parse/1)
+ |> Enum.reduce([], fn raw_job, acc ->
+ case RecordingJob.to_struct(raw_job) do
+ {:ok, job} ->
+ [job | acc]
+
+ {:error, changeset} ->
+ Logger.error("Discarding invalid recording: #{inspect(changeset)}")
+ acc
+ end
+ end)
+
+ {:ok, response}
+ end
+end
diff --git a/lib/recording/get_recordings.ex b/lib/recording/get_recordings.ex
new file mode 100644
index 0000000..35d69bb
--- /dev/null
+++ b/lib/recording/get_recordings.ex
@@ -0,0 +1,44 @@
+defmodule Onvif.Recording.GetRecordings do
+ import SweetXml
+ import XmlBuilder
+ require Logger
+
+ alias Onvif.Recording.Recordings
+
+ def soap_action, do: "http://www.onvif.org/ver10/recording/wsdl/GetRecordings"
+
+ def request(device) do
+ Onvif.Recording.request(device, __MODULE__)
+ end
+
+ def request_body() do
+ element(:"s:Body", [
+ element(:"trc:GetRecordings")
+ ])
+ end
+
+ def response(xml_response_body) do
+ response =
+ xml_response_body
+ |> parse(namespace_conformant: true, quiet: true)
+ |> xpath(
+ ~x"//s:Envelope/s:Body/trc:GetRecordingsResponse/trc:RecordingItem"el
+ |> add_namespace("s", "http://www.w3.org/2003/05/soap-envelope")
+ |> add_namespace("trc", "http://www.onvif.org/ver10/recording/wsdl")
+ |> add_namespace("tt", "http://www.onvif.org/ver10/schema")
+ )
+ |> Enum.map(&Recordings.parse/1)
+ |> Enum.reduce([], fn raw_recording, acc ->
+ case Recordings.to_struct(raw_recording) do
+ {:ok, recording} ->
+ [recording | acc]
+
+ {:error, changeset} ->
+ Logger.error("Discarding invalid recording: #{inspect(changeset)}")
+ acc
+ end
+ end)
+
+ {:ok, response}
+ end
+end
diff --git a/lib/recording/recording_job.ex b/lib/recording/recording_job.ex
new file mode 100644
index 0000000..2e86530
--- /dev/null
+++ b/lib/recording/recording_job.ex
@@ -0,0 +1,150 @@
+defmodule Onvif.Recording.RecordingJob do
+ @moduledoc """
+ Recordings.
+ """
+
+ use Ecto.Schema
+ import Ecto.Changeset
+ import SweetXml
+
+ @primary_key false
+ @derive Jason.Encoder
+ @required [:job_token]
+ @optional []
+
+ embedded_schema do
+ field(:job_token, :string)
+
+ embeds_one :job_configuration, JobConfiguration, primary_key: false, on_replace: :update do
+ @derive Jason.Encoder
+ field(:recording_token, :string)
+ field(:mode, :string)
+ field(:priority, :string)
+
+ embeds_one :source, Source, primary_key: false, on_replace: :update do
+ @derive Jason.Encoder
+ field(:auto_create_receiver, :boolean)
+
+ embeds_one :source_token, SourceToken, primary_key: false, on_replace: :update do
+ @derive Jason.Encoder
+ field(:token, :string)
+ end
+
+ embeds_many :tracks, Tracks, primary_key: false, on_replace: :delete do
+ @derive Jason.Encoder
+ field(:source_tag, :string)
+ field(:destination, :string)
+ end
+ end
+ end
+ end
+
+ def parse(nil), do: nil
+ def parse([]), do: nil
+
+ def parse(doc) do
+ xmap(
+ doc,
+ job_token: ~x"./tt:JobToken/text()"so,
+ job_configuration: ~x"./tt:JobConfiguration"eo |> transform_by(&parse_job_configuration/1)
+ )
+ end
+
+ def parse_job_configuration([]), do: nil
+ def parse_job_configuration(nil), do: nil
+
+ def parse_job_configuration(doc) do
+ xmap(
+ doc,
+ recording_token: ~x"./tt:RecordingToken/text()"so,
+ mode: ~x"./tt:Mode/text()"so,
+ priority: ~x"./tt:Priority/text()"so,
+ source: ~x"./tt:Source"eo |> transform_by(&parse_source/1)
+ )
+ end
+
+ def parse_source([]), do: nil
+ def parse_source(nil), do: nil
+
+ def parse_source(doc) do
+ xmap(
+ doc,
+ source_token: ~x"./tt:SourceToken"eo |> transform_by(&parse_source_token/1),
+ auto_create_receiver: ~x"./tt:AutoCreateReceiver/text()"so,
+ tracks: ~x"./tt:Tracks"elo |> transform_by(&parse_track/1)
+ )
+ end
+
+ def parse_source_token([]), do: nil
+ def parse_source_token(nil), do: nil
+
+ def parse_source_token(doc) do
+ xmap(
+ doc,
+ token: ~x"./tt:Token/text()"so
+ )
+ end
+
+ def parse_track([]), do: nil
+ def parse_track(nil), do: nil
+
+ def parse_track(docs) do
+ Enum.map(docs, fn doc ->
+ xmap(
+ doc,
+ source_tag: ~x"./tt:SourceTag/text()"so,
+ destination: ~x"./tt:Destination/text()"so
+ )
+ end)
+ end
+
+ def to_struct(parsed) do
+ %__MODULE__{}
+ |> changeset(parsed)
+ |> apply_action(:validate)
+ end
+
+ @spec to_json(%Onvif.Recording.RecordingJob{}) ::
+ {:error,
+ %{
+ :__exception__ => any,
+ :__struct__ => Jason.EncodeError | Protocol.UndefinedError,
+ optional(atom) => any
+ }}
+ | {:ok, binary}
+ def to_json(%__MODULE__{} = schema) do
+ Jason.encode(schema)
+ end
+
+ def changeset(module, attrs) do
+ module
+ |> cast(attrs, @required ++ @optional)
+ |> validate_required(@required)
+ |> cast_embed(:job_configuration, with: &job_configuration_changeset/2)
+ end
+
+ def job_configuration_changeset(schema, params) do
+ schema
+ |> cast(params, [:recording_token, :mode, :priority])
+ |> validate_required([:recording_token, :mode, :priority])
+ |> cast_embed(:source, with: &source_changeset/2)
+ end
+
+ def source_changeset(schema, params) do
+ schema
+ |> cast(params, [:auto_create_receiver])
+ |> cast_embed(:source_token, with: &source_token_changeset/2)
+ |> cast_embed(:tracks, with: &track_changeset/2)
+ end
+
+ def source_token_changeset(schema, params) do
+ schema
+ |> cast(params, [:token])
+ |> validate_required([:token])
+ end
+
+ def track_changeset(schema, params) do
+ schema
+ |> cast(params, [:source_tag, :destination])
+ end
+end
diff --git a/lib/recording/recordings.ex b/lib/recording/recordings.ex
new file mode 100644
index 0000000..7ef8eb9
--- /dev/null
+++ b/lib/recording/recordings.ex
@@ -0,0 +1,168 @@
+defmodule Onvif.Recording.Recordings do
+ @moduledoc """
+ Onvif.Recording.Recordings schema.
+ """
+
+ use Ecto.Schema
+ import Ecto.Changeset
+ import SweetXml
+
+ @primary_key false
+ @derive Jason.Encoder
+ @required [:recording_token]
+ @optional []
+
+ embedded_schema do
+ field(:recording_token, :string)
+
+ embeds_one :configuration, Configuration, primary_key: false, on_replace: :update do
+ @derive Jason.Encoder
+ field(:content, :string)
+ field(:maximum_retention_time, :string)
+
+ embeds_one(:source, Source, primary_key: false, on_replace: :update) do
+ @derive Jason.Encoder
+ field(:source_id, :string)
+ field(:name, :string)
+ field(:location, :string)
+ field(:description, :string)
+ field(:address, :string)
+ end
+ end
+
+ embeds_one(:tracks, Tracks, primary_key: false, on_replace: :update) do
+ @derive Jason.Encoder
+ embeds_many(:track, Track, primary_key: false, on_replace: :delete) do
+ @derive Jason.Encoder
+ field(:track_token, :string)
+
+ embeds_one(:configuration, Configuration, primary_key: false, on_replace: :update) do
+ @derive Jason.Encoder
+ field(:track_type, :string)
+ field(:description, :string)
+ end
+ end
+ end
+ end
+
+ def parse(nil), do: nil
+ def parse([]), do: nil
+
+ def parse(doc) do
+ xmap(
+ doc,
+ recording_token: ~x"./tt:RecordingToken/text()"so,
+ configuration: ~x"./tt:Configuration"eo |> transform_by(&parse_configuration/1),
+ tracks: ~x"./tt:Tracks"eo |> transform_by(&parse_tracks/1)
+ )
+ end
+
+ def parse_configuration([]), do: nil
+ def parse_configuration(nil), do: nil
+
+ def parse_configuration(doc) do
+ xmap(
+ doc,
+ content: ~x"./tt:Content/text()"so,
+ maximum_retention_time: ~x"./tt:MaximumRetentionTime/text()"so,
+ source: ~x"./tt:Source"eo |> transform_by(&parse_source/1)
+ )
+ end
+
+ def parse_source([]), do: nil
+ def parse_source(nil), do: nil
+
+ def parse_source(doc) do
+ xmap(
+ doc,
+ source_id: ~x"./tt:SourceId/text()"so,
+ name: ~x"./tt:Name/text()"so,
+ location: ~x"./tt:Location/text()"so,
+ description: ~x"./tt:Description/text()"so,
+ address: ~x"./tt:Address/text()"so
+ )
+ end
+
+ def parse_tracks([]), do: nil
+ def parse_tracks(nil), do: nil
+
+ def parse_tracks(doc) do
+ xmap(
+ doc,
+ track: ~x"./tt:Track"elo |> transform_by(&parse_track/1)
+ )
+ end
+
+ def parse_track([]), do: nil
+ def parse_track(nil), do: nil
+
+ def parse_track(docs) do
+ Enum.map(docs, fn doc ->
+ xmap(
+ doc,
+ track_token: ~x"./tt:TrackToken/text()"so,
+ configuration: ~x"./tt:Configuration"eo |> transform_by(&parse_track_configuration/1)
+ )
+ end)
+ end
+
+ def parse_track_configuration([]), do: nil
+ def parse_track_configuration(nil), do: nil
+
+ def parse_track_configuration(doc) do
+ xmap(
+ doc,
+ track_type: ~x"./tt:TrackType/text()"so,
+ description: ~x"./tt:Description/text()"so
+ )
+ end
+
+ def to_struct(parsed) do
+ %__MODULE__{}
+ |> changeset(parsed)
+ |> apply_action(:validate)
+ end
+
+ @spec to_json(%Onvif.Recording.Recordings{}) ::
+ {:error,
+ %{
+ :__exception__ => any,
+ :__struct__ => Jason.EncodeError | Protocol.UndefinedError,
+ optional(atom) => any
+ }}
+ | {:ok, binary}
+ def to_json(%__MODULE__{} = schema) do
+ Jason.encode(schema)
+ end
+
+ def changeset(module, attrs) do
+ module
+ |> cast(attrs, @required ++ @optional)
+ |> validate_required(@required)
+ |> cast_embed(:configuration, with: &configuration_changeset/2)
+ |> cast_embed(:tracks, with: &tracks_changeset/2)
+ end
+
+ def configuration_changeset(module, attrs) do
+ cast(module, attrs, [:content, :maximum_retention_time])
+ |> cast_embed(:source, with: &source_changeset/2)
+ end
+
+ def source_changeset(module, attrs) do
+ cast(module, attrs, [:source_id, :name, :location, :description, :address])
+ end
+
+ def tracks_changeset(module, attrs) do
+ cast(module, attrs, [])
+ |> cast_embed(:track, with: &track_changeset/2)
+ end
+
+ def track_changeset(module, attrs) do
+ cast(module, attrs, [:track_token])
+ |> cast_embed(:configuration, with: &track_configuration_changeset/2)
+ end
+
+ def track_configuration_changeset(module, attrs) do
+ cast(module, attrs, [:track_type, :description])
+ end
+end
diff --git a/lib/replay.ex b/lib/replay.ex
new file mode 100644
index 0000000..7f264e6
--- /dev/null
+++ b/lib/replay.ex
@@ -0,0 +1,56 @@
+defmodule Onvif.Replay do
+ @moduledoc """
+ Interface for making requests to the Onvif replay service
+ https://www.onvif.org/ver10/replay.wsdl
+ """
+ require Logger
+ alias Onvif.Device
+
+ @namespaces [
+ "xmlns:trp": "http://www.onvif.org/ver10/replay/wsdl",
+ "xmlns:tt": "http://www.onvif.org/ver10/schema"
+ ]
+
+ @spec request(Device.t(), module()) :: {:ok, any} | {:error, map()}
+ def request(%Device{} = device, operation) do
+ content = generate_content(operation)
+ do_request(device, operation, content)
+ end
+
+ def request(%Device{} = device, args, operation) do
+ content = generate_content(operation, args)
+ do_request(device, operation, content)
+ end
+
+ defp do_request(device, operation, content) do
+ device
+ |> Onvif.API.client(service_path: :replay_service_path)
+ |> Tesla.request(
+ method: :post,
+ headers: [{"Content-Type", "application/soap+xml"}, {"SOAPAction", operation.soap_action()}],
+ body: %Onvif.Request{content: content, namespaces: @namespaces}
+ )
+ |> parse_response(operation)
+ end
+
+ defp generate_content(operation), do: operation.request_body()
+ defp generate_content(operation, args), do: operation.request_body(args)
+
+ defp parse_response({:ok, %{status: 200, body: body}}, operation) do
+ operation.response(body)
+ end
+
+ defp parse_response({:ok, %{status: status_code, body: body}}, operation)
+ when status_code >= 400,
+ do:
+ {:error,
+ %{
+ status: status_code,
+ reason: "Received #{status_code} from #{operation}",
+ response: body
+ }}
+
+ defp parse_response({:error, response}, operation) do
+ {:error, %{status: nil, reason: "Error performing #{operation}", response: response}}
+ end
+end
diff --git a/lib/replay/get_replay_uri.ex b/lib/replay/get_replay_uri.ex
new file mode 100644
index 0000000..1f57bcd
--- /dev/null
+++ b/lib/replay/get_replay_uri.ex
@@ -0,0 +1,38 @@
+defmodule Onvif.Replay.GetReplayUri do
+ import SweetXml
+ import XmlBuilder
+ require Logger
+
+ def soap_action, do: "http://www.onvif.org/ver10/replay/wsdl/GetReplayUri"
+
+ def request(device, args) do
+ Onvif.Replay.request(device, args, __MODULE__)
+ end
+
+ def request_body(token, ss_stream \\ "RTP-Unicast", ss_protocol \\ "TCP") do
+ element(:"s:Body", [
+ element(:"trp:GetReplayUri", [
+ element(:"trp:StreamSetup", [
+ element(:"tt:Stream", ss_stream),
+ element(:"tt:Transport", [
+ element(:"tt:Protocol", ss_protocol)
+ ])
+ ]),
+ element(:"trp:RecordingToken", token)
+ ])
+ ])
+ end
+
+ def response(xml_response_body) do
+ parsed_result =
+ xml_response_body
+ |> parse(namespace_conformant: true, quiet: true)
+ |> xpath(
+ ~x"//trp:Uri/text()"s
+ |> add_namespace("s", "http://www.w3.org/2003/05/soap-envelope")
+ |> add_namespace("trp", "http://www.onvif.org/ver10/replay/wsdl")
+ )
+
+ {:ok, parsed_result}
+ end
+end
diff --git a/lib/replay/get_service_capabilities.ex b/lib/replay/get_service_capabilities.ex
new file mode 100644
index 0000000..b1ff809
--- /dev/null
+++ b/lib/replay/get_service_capabilities.ex
@@ -0,0 +1,43 @@
+defmodule Onvif.Replay.GetServiceCapabilities do
+ import SweetXml
+ import XmlBuilder
+ require Logger
+
+ def soap_action, do: "http://www.onvif.org/ver10/replay/wsdl/GetServiceCapabilities"
+
+ def request(device) do
+ Onvif.Replay.request(device, __MODULE__)
+ end
+
+ def request_body() do
+ element(:"s:Body", [
+ element(:"trp:GetServiceCapabilities")
+ ])
+ end
+
+ def response(xml_response_body) do
+ doc = parse(xml_response_body, namespace_conformant: true, quiet: true)
+
+ parsed_result =
+ xpath(
+ doc,
+ ~x"//s:Envelope/s:Body/trp:GetServiceCapabilitiesResponse/trp:Capabilities"
+ |> add_namespace("s", "http://www.w3.org/2003/05/soap-envelope")
+ |> add_namespace("trp", "http://www.onvif.org/ver10/replay/wsdl")
+ |> add_namespace("tt", "http://www.onvif.org/ver10/schema"),
+ rtp_rtsp_tcp: ~x"//@RTP_RTSP_TCP"so,
+ reverse_playback: ~x"//@ReversePlayback"so,
+ session_timeout_range: ~x"//@SessionTimeoutRange"so,
+ rtsp_web_socket_uri: ~x"//@RTSPWebSocketUri"so,
+ receive_source: ~x"//tt:CapabilitiesExtension/RecordingCapabilities/@ReceiverSource"so,
+ media_profile_source:
+ ~x"//tt:CapabilitiesExtension/RecordingCapabilities/@MediaProfileSource"so,
+ dynamic_recordings:
+ ~x"//tt:CapabilitiesExtension/RecordingCapabilities/@DynamicRecordings"so,
+ dynamic_tracks: ~x"//tt:CapabilitiesExtension/RecordingCapabilities/@DynamicTracks"so,
+ max_string_length: ~x"//tt:CapabilitiesExtension/RecordingCapabilities/@MaxStringLength"so
+ )
+
+ {:ok, Onvif.Replay.ServiceCapabilities.from_parsed(parsed_result)}
+ end
+end
diff --git a/lib/replay/service_capabilities.ex b/lib/replay/service_capabilities.ex
new file mode 100644
index 0000000..78fa78d
--- /dev/null
+++ b/lib/replay/service_capabilities.ex
@@ -0,0 +1,45 @@
+defmodule Onvif.Replay.ServiceCapabilities do
+ @fields [
+ rtp_rtsp_tcp: false,
+ reverse_playback: false,
+ session_timeout_range: "0",
+ rtsp_web_socket_uri: false,
+ receive_source: false,
+ media_profile_source: false,
+ dynamic_recordings: false,
+ dynamic_tracks: false,
+ max_string_length: "0"
+ ]
+
+ defstruct Keyword.keys(@fields)
+
+ @type t() :: %__MODULE__{
+ rtp_rtsp_tcp: boolean(),
+ reverse_playback: boolean(),
+ session_timeout_range: String.t(),
+ rtsp_web_socket_uri: boolean(),
+ receive_source: boolean(),
+ media_profile_source: boolean(),
+ dynamic_recordings: boolean(),
+ dynamic_tracks: boolean(),
+ max_string_length: integer()
+ }
+
+ @doc """
+ Converts a parsed map into a %Onvif.Replay.ServiceCapabilities{} struct with validated types.
+ """
+ def from_parsed(parsed) do
+ # Ensure only valid keys and convert values
+ converted =
+ @fields
+ |> Enum.map(fn {key, _default} -> {key, convert_value(key, Map.get(parsed, key))} end)
+ |> Enum.into(%{})
+
+ struct(__MODULE__, converted)
+ end
+
+ defp convert_value(_key, "true"), do: true
+ defp convert_value(_key, "false"), do: false
+ defp convert_value(_key, nil), do: nil
+ defp convert_value(_key, value), do: value
+end
diff --git a/test/recording/create_recording_job_test.exs b/test/recording/create_recording_job_test.exs
new file mode 100644
index 0000000..2437cb9
--- /dev/null
+++ b/test/recording/create_recording_job_test.exs
@@ -0,0 +1,21 @@
+defmodule Onvif.Recording.CreateRecordingJobTest do
+ use ExUnit.Case, async: true
+
+ @moduletag capture_log: true
+
+ describe "CreateRecordingJob/2" do
+ test "create a recording" do
+ xml_response = File.read!("test/recording/fixture/create_recording_job_success.xml")
+
+ device = Onvif.Factory.device()
+
+ Mimic.expect(Tesla, :request, fn _client, _opts ->
+ {:ok, %{status: 200, body: xml_response}}
+ end)
+
+ {:ok, response} = Onvif.Recording.CreateRecordingJob.request(device, ["SD_DISK_20241120_211729_9C896594", "9", "Active"])
+
+ assert response == "SD_DISK_20241120_211729_9C896594"
+ end
+ end
+end
diff --git a/test/recording/create_recording_test.exs b/test/recording/create_recording_test.exs
new file mode 100644
index 0000000..eff84bf
--- /dev/null
+++ b/test/recording/create_recording_test.exs
@@ -0,0 +1,29 @@
+defmodule Onvif.Recording.CreateRecordingTest do
+ use ExUnit.Case, async: true
+
+ @moduletag capture_log: true
+
+ describe "CreateRecording/2" do
+ test "create a recording" do
+ xml_response = File.read!("test/recording/fixture/create_recording_success.xml")
+
+ device = Onvif.Factory.device()
+
+ Mimic.expect(Tesla, :request, fn _client, _opts ->
+ {:ok, %{status: 200, body: xml_response}}
+ end)
+
+ {:ok, response_uri} = Onvif.Recording.CreateRecording.request(
+ device,
+ config: %Onvif.Recording.Recordings.Configuration{
+ content: "test",
+ maximum_retention_time: "PT1H",
+ source: %Onvif.Recording.Recordings.Configuration.Source{
+ name: "test",
+ }
+ })
+
+ assert response_uri == "SD_DISK_20200422_123501_A2388AB3"
+ end
+ end
+end
diff --git a/test/recording/fixture/create_recording_job_success.xml b/test/recording/fixture/create_recording_job_success.xml
new file mode 100644
index 0000000..82abbc4
--- /dev/null
+++ b/test/recording/fixture/create_recording_job_success.xml
@@ -0,0 +1,64 @@
+
+
+
+
+ SD_DISK_20241120_211729_9C896594
+
+ SD_DISK_20241120_211729_9C896594
+ Active
+ 9
+
+
+
+
\ No newline at end of file
diff --git a/test/recording/fixture/create_recording_success.xml b/test/recording/fixture/create_recording_success.xml
new file mode 100644
index 0000000..9ed4a7d
--- /dev/null
+++ b/test/recording/fixture/create_recording_success.xml
@@ -0,0 +1,59 @@
+
+
+
+
+ SD_DISK_20200422_123501_A2388AB3
+
+
+
\ No newline at end of file
diff --git a/test/recording/fixture/get_recording_jobs_empty.xml b/test/recording/fixture/get_recording_jobs_empty.xml
new file mode 100644
index 0000000..b73e9b9
--- /dev/null
+++ b/test/recording/fixture/get_recording_jobs_empty.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/recording/fixture/get_recording_jobs_success.xml b/test/recording/fixture/get_recording_jobs_success.xml
new file mode 100644
index 0000000..aeba527
--- /dev/null
+++ b/test/recording/fixture/get_recording_jobs_success.xml
@@ -0,0 +1,84 @@
+
+
+
+
+
+ SD_DISK_20241120_211729_9C896594
+
+ SD_DISK_20241120_211729_9C896594
+ Active
+ 9
+
+
+ profile_1_h264
+
+ false
+
+ tag0
+ VIDEO001
+
+
+ tag1
+ AUDIO001
+
+
+ tag2
+ META001
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/recording/fixture/get_recordings_success.xml b/test/recording/fixture/get_recordings_success.xml
new file mode 100644
index 0000000..b014e53
--- /dev/null
+++ b/test/recording/fixture/get_recordings_success.xml
@@ -0,0 +1,169 @@
+
+
+
+
+
+ SD_DISK_20200422_123501_A2388AB3
+
+
+
+ paolo
+
+
+
+
+ beta cam 1
+ PT1H
+
+
+
+ VIDEO001
+
+ Video
+
+
+
+
+ AUDIO001
+
+ Audio
+
+
+
+
+ META001
+
+ Metadata
+
+
+
+
+
+
+ SD_DISK_20200422_132613_45A883F5
+
+
+
+ paolo2
+
+
+
+
+ beta cam 2
+ PT1H
+
+
+
+ VIDEO001
+
+ Video
+
+
+
+
+ AUDIO001
+
+ Audio
+
+
+
+
+ META001
+
+ Metadata
+
+
+
+
+
+
+ SD_DISK_20200422_132655_67086B52
+
+
+
+ paolo3
+
+
+
+
+ beta cam 3
+ PT1H
+
+
+
+ VIDEO001
+
+ Video
+
+
+
+
+ AUDIO001
+
+ Audio
+
+
+
+
+ META001
+
+ Metadata
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/recording/get_recording_jobs_test.exs b/test/recording/get_recording_jobs_test.exs
new file mode 100644
index 0000000..469eef5
--- /dev/null
+++ b/test/recording/get_recording_jobs_test.exs
@@ -0,0 +1,35 @@
+defmodule Onvif.Recording.GetRecordingJobsTest do
+ use ExUnit.Case, async: true
+
+ @moduletag capture_log: true
+
+ describe "GetRecordingJobs/1" do
+ test "get recording jobs" do
+ xml_response = File.read!("test/recording/fixture/get_recording_jobs_success.xml")
+
+ device = Onvif.Factory.device()
+
+ Mimic.expect(Tesla, :request, fn _client, _opts ->
+ {:ok, %{status: 200, body: xml_response}}
+ end)
+
+ {:ok, response} = Onvif.Recording.GetRecordingJobs.request(device)
+
+ assert hd(response).job_token == "SD_DISK_20241120_211729_9C896594"
+ end
+
+ test "empty recording job" do
+ xml_response = File.read!("test/recording/fixture/get_recording_jobs_empty.xml")
+
+ device = Onvif.Factory.device()
+
+ Mimic.expect(Tesla, :request, fn _client, _opts ->
+ {:ok, %{status: 200, body: xml_response}}
+ end)
+
+ {:ok, response} = Onvif.Recording.GetRecordingJobs.request(device)
+
+ assert response == []
+ end
+ end
+end
diff --git a/test/recording/get_recordings_test.exs b/test/recording/get_recordings_test.exs
new file mode 100644
index 0000000..78dae0c
--- /dev/null
+++ b/test/recording/get_recordings_test.exs
@@ -0,0 +1,27 @@
+defmodule Onvif.Recording.GetRecordingsTest do
+ use ExUnit.Case, async: true
+
+ @moduletag capture_log: true
+
+ describe "GetRecordings/1" do
+ test "successfully get a list of recordings" do
+ xml_response = File.read!("test/recording/fixture/get_recordings_success.xml")
+
+ device = Onvif.Factory.device()
+
+ Mimic.expect(Tesla, :request, fn _client, _opts ->
+ {:ok, %{status: 200, body: xml_response}}
+ end)
+
+ {:ok, response} = Onvif.Recording.GetRecordings.request(device)
+
+ assert Enum.map(response, fn r ->
+ r.recording_token
+ end) == [
+ "SD_DISK_20200422_132655_67086B52",
+ "SD_DISK_20200422_132613_45A883F5",
+ "SD_DISK_20200422_123501_A2388AB3"
+ ]
+ end
+ end
+end
diff --git a/test/replay/fixtures/get_replay_uri__recording_not_found.xml b/test/replay/fixtures/get_replay_uri__recording_not_found.xml
new file mode 100644
index 0000000..186a28e
--- /dev/null
+++ b/test/replay/fixtures/get_replay_uri__recording_not_found.xml
@@ -0,0 +1,73 @@
+
+
+
+
+
+ SOAP-ENV:Sender
+
+ ter:InvalidArgVal
+
+ ter:NoRecording
+
+
+
+
+ Recording not found
+
+
+ The requested recording with the specified RecordingToken can not be found
+
+
+
+
\ No newline at end of file
diff --git a/test/replay/fixtures/get_replay_uri__success.xml b/test/replay/fixtures/get_replay_uri__success.xml
new file mode 100644
index 0000000..46854b6
--- /dev/null
+++ b/test/replay/fixtures/get_replay_uri__success.xml
@@ -0,0 +1,59 @@
+
+
+
+
+ rtsp://192.168.1.136/onvif-media/record/play.amp?onvifreplayid=SD_DISK_20200422_132655_67086B52&onvifreplayext=1&streamtype=unicast&session_timeout=30
+
+
+
\ No newline at end of file
diff --git a/test/replay/fixtures/get_service_capabilities_success.xml b/test/replay/fixtures/get_service_capabilities_success.xml
new file mode 100644
index 0000000..bc1cd74
--- /dev/null
+++ b/test/replay/fixtures/get_service_capabilities_success.xml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/replay/get_replay_uri_test.exs b/test/replay/get_replay_uri_test.exs
new file mode 100644
index 0000000..e6a67d8
--- /dev/null
+++ b/test/replay/get_replay_uri_test.exs
@@ -0,0 +1,37 @@
+defmodule Onvif.Replay.GetReplayUriTest do
+ use ExUnit.Case, async: true
+
+ @moduletag capture_log: true
+
+ describe "GetReplayUri/2" do
+ test "get a replay uri" do
+ xml_response = File.read!("test/replay/fixtures/get_replay_uri__success.xml")
+
+ device = Onvif.Factory.device()
+
+ Mimic.expect(Tesla, :request, fn _client, _opts ->
+ {:ok, %{status: 200, body: xml_response}}
+ end)
+
+ {:ok, response} =
+ Onvif.Replay.GetReplayUri.request(device, ["SD_DISK_20200422_132655_67086B52"])
+
+ assert response ==
+ "rtsp://192.168.1.136/onvif-media/record/play.amp?onvifreplayid=SD_DISK_20200422_132655_67086B52&onvifreplayext=1&streamtype=unicast&session_timeout=30"
+ end
+
+ test "get no uri" do
+ xml_response = File.read!("test/replay/fixtures/get_replay_uri__recording_not_found.xml")
+
+ device = Onvif.Factory.device()
+
+ Mimic.expect(Tesla, :request, fn _client, _opts ->
+ {:ok, %{status: 200, body: xml_response}}
+ end)
+
+ {:ok, response} = Onvif.Replay.GetReplayUri.request(device, ["SD_DISK_000"])
+
+ assert response == ""
+ end
+ end
+end
diff --git a/test/replay/get_service_capabilities_test.exs b/test/replay/get_service_capabilities_test.exs
new file mode 100644
index 0000000..002752f
--- /dev/null
+++ b/test/replay/get_service_capabilities_test.exs
@@ -0,0 +1,31 @@
+defmodule Onvif.Replay.GetServiceCapabilitiesTest do
+ use ExUnit.Case, async: true
+
+ @moduletag capture_log: true
+
+ describe "GetServiceCapabilities/1" do
+ test "get service capabilities" do
+ xml_response = File.read!("test/replay/fixtures/get_service_capabilities_success.xml")
+
+ device = Onvif.Factory.device()
+
+ Mimic.expect(Tesla, :request, fn _client, _opts ->
+ {:ok, %{status: 200, body: xml_response}}
+ end)
+
+ {:ok, response} = Onvif.Replay.GetServiceCapabilities.request(device)
+
+ assert response == %Onvif.Replay.ServiceCapabilities{
+ dynamic_recordings: true,
+ dynamic_tracks: false,
+ max_string_length: "4096",
+ media_profile_source: true,
+ receive_source: false,
+ reverse_playback: false,
+ rtp_rtsp_tcp: true,
+ rtsp_web_socket_uri: "",
+ session_timeout_range: "0 4294967295"
+ }
+ end
+ end
+end