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