diff --git a/lib/device.ex b/lib/device.ex index b1705bb..29a9367 100644 --- a/lib/device.ex +++ b/lib/device.ex @@ -31,6 +31,7 @@ defmodule Onvif.Device do :recording_ver10_service_path, :replay_ver10_service_path, :search_ver10_service_path, + :ptz_ver20_service_path, :auth_type, :time_diff_from_system_secs, :port, @@ -57,6 +58,7 @@ defmodule Onvif.Device do field(:recording_ver10_service_path, :string) field(:replay_ver10_service_path, :string) field(:search_ver10_service_path, :string) + field(:ptz_ver20_service_path, :string) embeds_one(:system_date_time, SystemDateAndTime) embeds_many(:services, Service) @@ -315,6 +317,7 @@ defmodule Onvif.Device do |> Map.put(:recording_ver10_service_path, get_recoding_ver10_service_path(device.services)) |> Map.put(:replay_ver10_service_path, get_replay_ver10_service_path(device.services)) |> Map.put(:search_ver10_service_path, get_search_ver10_service_path(device.services)) + |> Map.put(:ptz_ver20_service_path, get_ptz_ver20_service_path(device.services)) end defp get_media_ver20_service_path(services) do @@ -351,4 +354,11 @@ defmodule Onvif.Device do %Service{} = service -> service.xaddr |> URI.parse() |> Map.get(:path) end end + + defp get_ptz_ver20_service_path(services) do + case Enum.find(services, &String.contains?(&1.namespace, "/ptz")) do + nil -> nil + %Service{} = service -> service.xaddr |> URI.parse() |> Map.get(:path) + end + end end diff --git a/lib/factory.ex b/lib/factory.ex index 7b28c4b..fcf7067 100644 --- a/lib/factory.ex +++ b/lib/factory.ex @@ -14,6 +14,7 @@ defmodule Onvif.Factory do replay_ver10_service_path: "/onvif/replay_service", recording_ver10_service_path: "/onvif/recording_service", search_ver10_service_path: "/onvif/search_service", + ptz_ver20_service_path: "/onvif/ptz_service", model: "N864A6", ntp: "NTP", password: "admin", @@ -84,7 +85,12 @@ defmodule Onvif.Factory do namespace: "http://www.onvif.org/ver10/device/wsdl", version: "18.12", xaddr: "http://192.168.254.89/onvif/device_service" - } + }, + %Onvif.Devices.Schemas.Service{ + namespace: "http://www.onvif.org/ver20/ptz/wsdl", + xaddr: "http://192.168.254.89/onvif/ptz_service", + version: "22.12" + }, ], time_diff_from_system_secs: 3597, username: "admin" diff --git a/lib/ptz/get_nodes.ex b/lib/ptz/get_nodes.ex new file mode 100644 index 0000000..fe45406 --- /dev/null +++ b/lib/ptz/get_nodes.ex @@ -0,0 +1,49 @@ +defmodule Onvif.PTZ.GetNodes do + @moduledoc """ + Get the descriptions of the available PTZ Nodes. + + A PTZ-capable device may have multiple PTZ Nodes. The PTZ Nodes may represent mechanical PTZ drivers, uploaded PTZ drivers or digital PTZ drivers. + PTZ Nodes are the lowest level entities in the PTZ control API and reflect the supported PTZ capabilities. + + The PTZ Node is referenced either by its name or by its reference token. + """ + + import SweetXml + import XmlBuilder + + require Logger + + alias Onvif.PTZ.Schemas.PTZNode + + def soap_action(), do: "http://www.onvif.org/ver20/ptz/wsdl/GetNodes" + + @spec request(Device.t()) :: {:ok, [PTZNode.t()]} | {:error, map()} + def request(device), do: Onvif.PTZ.request(device, __MODULE__) + + def request_body(), do: element(:"s:Body", [:"tptz:GetNodes"]) + + def response(xml_response_body) do + response = + xml_response_body + |> parse(namespace_conformant: true, quiet: true) + |> xpath( + ~x"//s:Envelope/s:Body/tptz:GetNodesResponse/tptz:PTZNode"el + |> add_namespace("s", "http://www.w3.org/2003/05/soap-envelope") + |> add_namespace("tt", "http://www.onvif.org/ver10/schema") + |> add_namespace("tptz", "http://www.onvif.org/ver20/ptz/wsdl") + ) + |> Enum.map(&Onvif.PTZ.Schemas.PTZNode.parse/1) + |> Enum.reduce([], fn raw_ptz_node, acc -> + case Onvif.PTZ.Schemas.PTZNode.to_struct(raw_ptz_node) do + {:ok, ptz_node} -> + [ptz_node | acc] + + {:error, changeset} -> + Logger.error("Discarding invalid PTZ node: #{inspect(changeset)}") + acc + end + end) + + {:ok, Enum.reverse(response)} + end +end diff --git a/lib/ptz/ptz.ex b/lib/ptz/ptz.ex new file mode 100644 index 0000000..c24fc55 --- /dev/null +++ b/lib/ptz/ptz.ex @@ -0,0 +1,54 @@ +defmodule Onvif.PTZ do + @moduledoc """ + Interface for making requests to the Onvif PTZ(Pan/Tilt/Zoom) service + + https://www.onvif.org/onvif/ver20/ptz/wsdl/ptz.wsdl + """ + require Logger + + alias Onvif.Device + + @namespaces [ + "xmlns:tt": "http://www.onvif.org/ver10/schema", + "xmlns:tptz": "http://www.onvif.org/ver20/ptz/wsdl" + ] + + @spec request(Device.t(), module()) :: {:ok, any} | {:error, map()} + @spec request(Device.t(), list(), atom()) :: {:ok, any} | {:error, map()} + 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: :ptz_ver20_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/ptz/schemas/ptz_node.ex b/lib/ptz/schemas/ptz_node.ex new file mode 100644 index 0000000..c284ccf --- /dev/null +++ b/lib/ptz/schemas/ptz_node.ex @@ -0,0 +1,140 @@ +defmodule Onvif.PTZ.Schemas.PTZNode do + @moduledoc """ + Module describing a PTZ node. + """ + + use Ecto.Schema + + import Ecto.Changeset + import SweetXml + + alias Onvif.PTZ.Schemas.{Space1DDescription, Space2DDescription} + + @type t :: %__MODULE__{} + + @primary_key false + @derive Jason.Encoder + embedded_schema do + field(:token, :string) + field(:fixed_home_position, :boolean) + field(:geo_move, :boolean) + field(:name, :string) + + embeds_one :supported_ptz_spaces, SupportedPTZSpaces, primary_key: false do + @derive Jason.Encoder + embeds_one(:absolute_pan_tilt_position_space, Space2DDescription) + embeds_one(:absolute_zoom_position_space, Space1DDescription) + embeds_one(:relative_pan_tilt_translation_space, Space2DDescription) + embeds_one(:relative_zoom_translation_space, Space1DDescription) + embeds_one(:continuous_pan_tilt_velocity_space, Space2DDescription) + embeds_one(:continuous_zoom_velocity_space, Space1DDescription) + embeds_one(:pan_tilt_speed_space, Space1DDescription) + embeds_one(:zoom_speed_space, Space1DDescription) + end + + field(:maximum_number_of_presets, :integer) + field(:home_supported, :boolean) + field(:auxiliary_commands, {:array, :string}) + + embeds_one :extension, Extension, primary_key: false do + embeds_one :supported_preset_tour, SupportedPresetTour, primary_key: false do + field(:maximum_number_of_preset_tours, :integer) + field(:ptz_preset_tour_operation, {:array, :string}) + end + end + end + + def to_struct(parsed) do + %__MODULE__{} + |> changeset(parsed) + |> apply_action(:validate) + end + + @spec to_json(__MODULE__.t()) :: {:error, Jason.EncodeError.t() | Exception.t()} | {:ok, binary} + def to_json(%__MODULE__{} = schema) do + Jason.encode(schema) + end + + def parse(doc) do + xmap( + doc, + token: ~x"./@token"s, + fixed_home_position: ~x"./tt:FixedHomePosition/text()"s, + geo_move: ~x"./tt:GeoMove/text()"s, + name: ~x"./tt:Name/text()"s, + supported_ptz_spaces: + ~x"./tt:SupportedPTZSpaces"e |> transform_by(&parse_supported_ptz_spaces/1), + maximum_number_of_presets: ~x"./tt:MaximumNumberOfPresets/text()"s, + home_supported: ~x"./tt:HomeSupported/text()"s, + auxiliary_commands: ~x"./tt:AuxiliaryCommands/text()"sl, + extension: ~x"./tt:Extension"e |> transform_by(&parse_extension/1) + ) + end + + def changeset(module, attrs) do + module + |> cast(attrs, __MODULE__.__schema__(:fields) -- [:supported_ptz_spaces, :extension]) + |> cast_embed(:supported_ptz_spaces, with: &supported_ptz_spaces_changeset/2) + |> cast_embed(:extension, with: &extension_changeset/2) + end + + defp parse_supported_ptz_spaces(doc) do + xmap( + doc, + absolute_pan_tilt_position_space: + ~x"./tt:AbsolutePanTiltPositionSpace"e |> transform_by(&Space2DDescription.parse/1), + absolute_zoom_position_space: + ~x"./tt:AbsoluteZoomPositionSpace"e |> transform_by(&Space1DDescription.parse/1), + relative_pan_tilt_translation_space: + ~x"./tt:RelativePanTiltTranslationSpace"e |> transform_by(&Space2DDescription.parse/1), + relative_zoom_translation_space: + ~x"./tt:RelativeZoomTranslationSpace"e |> transform_by(&Space1DDescription.parse/1), + continuous_pan_tilt_velocity_space: + ~x"./tt:ContinuousPanTiltVelocitySpace"e |> transform_by(&Space2DDescription.parse/1), + continuous_zoom_velocity_space: + ~x"./tt:ContinuousZoomVelocitySpace"e |> transform_by(&Space1DDescription.parse/1), + pan_tilt_speed_space: + ~x"./tt:PanTiltSpeedSpace"e |> transform_by(&Space1DDescription.parse/1), + zoom_speed_space: ~x"./tt:ZoomSpeedSpace"e |> transform_by(&Space1DDescription.parse/1) + ) + end + + defp supported_ptz_spaces_changeset(module, attrs) do + module + |> cast(attrs, []) + |> cast_embed(:absolute_pan_tilt_position_space, with: &Space2DDescription.changeset/2) + |> cast_embed(:absolute_zoom_position_space, with: &Space1DDescription.changeset/2) + |> cast_embed(:relative_pan_tilt_translation_space, with: &Space2DDescription.changeset/2) + |> cast_embed(:relative_zoom_translation_space, with: &Space1DDescription.changeset/2) + |> cast_embed(:continuous_pan_tilt_velocity_space, with: &Space2DDescription.changeset/2) + |> cast_embed(:continuous_zoom_velocity_space, with: &Space1DDescription.changeset/2) + |> cast_embed(:pan_tilt_speed_space, with: &Space1DDescription.changeset/2) + |> cast_embed(:zoom_speed_space, with: &Space1DDescription.changeset/2) + end + + defp parse_extension(doc) do + xmap( + doc, + supported_preset_tour: + ~x"./tt:SupportedPresetTour"e |> transform_by(&parse_supported_preset_tour/1) + ) + end + + defp parse_supported_preset_tour(doc) do + xmap( + doc, + maximum_number_of_preset_tours: ~x"./tt:MaximumNumberOfPresetTours/text()"s, + ptz_preset_tour_operation: ~x"./tt:PTZPresetTourOperation/text()"sl + ) + end + + defp extension_changeset(module, attrs) do + module + |> cast(attrs, []) + |> cast_embed(:supported_preset_tour, with: &supported_preset_tour_changeset/2) + end + + defp supported_preset_tour_changeset(module, attrs) do + cast(module, attrs, [:maximum_number_of_preset_tours, :ptz_preset_tour_operation]) + end +end diff --git a/lib/ptz/schemas/space1d_description.ex b/lib/ptz/schemas/space1d_description.ex new file mode 100644 index 0000000..1466e09 --- /dev/null +++ b/lib/ptz/schemas/space1d_description.ex @@ -0,0 +1,37 @@ +defmodule Onvif.PTZ.Schemas.Space1DDescription do + @moduledoc """ + Module describing a 1D space. + """ + + use Ecto.Schema + + import Ecto.Changeset + import SweetXml + + alias Onvif.Schemas.FloatRange + + @type t :: %__MODULE__{} + + @primary_key false + @derive Jason.Encoder + embedded_schema do + field(:uri, :string) + embeds_one(:x_range, FloatRange) + end + + def parse(nil), do: nil + + def parse(doc) do + xmap( + doc, + uri: ~x"./tt:URI/text()"s, + x_range: ~x"./tt:XRange"e |> transform_by(&FloatRange.parse/1) + ) + end + + def changeset(space1d_description, attrs) do + space1d_description + |> cast(attrs, [:uri]) + |> cast_embed(:x_range, with: &FloatRange.changeset/2) + end +end diff --git a/lib/ptz/schemas/space2d_description.ex b/lib/ptz/schemas/space2d_description.ex new file mode 100644 index 0000000..8330ee5 --- /dev/null +++ b/lib/ptz/schemas/space2d_description.ex @@ -0,0 +1,41 @@ +defmodule Onvif.PTZ.Schemas.Space2DDescription do + @moduledoc """ + Module describing a 2D space. + """ + + use Ecto.Schema + + import Ecto.Changeset + import SweetXml + + alias Onvif.Schemas.FloatRange + + @type t :: %__MODULE__{} + + @primary_key false + @derive Jason.Encoder + embedded_schema do + field(:uri, :string) + + embeds_one(:x_range, FloatRange) + embeds_one(:y_range, FloatRange) + end + + def parse(nil), do: nil + + def parse(doc) do + xmap( + doc, + uri: ~x"./tt:URI/text()"s, + x_range: ~x"./tt:XRange"e |> transform_by(&FloatRange.parse/1), + y_range: ~x"./tt:YRange"e |> transform_by(&FloatRange.parse/1) + ) + end + + def changeset(space2d_description, attrs) do + space2d_description + |> cast(attrs, [:uri]) + |> cast_embed(:x_range, with: &FloatRange.changeset/2) + |> cast_embed(:y_range, with: &FloatRange.changeset/2) + end +end diff --git a/lib/schemas/float_range.ex b/lib/schemas/float_range.ex new file mode 100644 index 0000000..3688323 --- /dev/null +++ b/lib/schemas/float_range.ex @@ -0,0 +1,41 @@ +defmodule Onvif.Schemas.FloatRange do + @moduledoc """ + Module describing a float range. + """ + + use Ecto.Schema + + import Ecto.Changeset + import SweetXml + import XmlBuilder + + @type t :: %__MODULE__{} + + @primary_key false + @derive Jason.Encoder + embedded_schema do + field(:min, :float) + field(:max, :float) + end + + def parse(nil), do: nil + + def parse(doc) do + xmap( + doc, + min: ~x"./tt:Min/text()"s, + max: ~x"./tt:Max/text()"s + ) + end + + def to_xml(struct) do + [ + element(:"tt:Min", struct.min), + element(:"tt:Max", struct.max) + ] + end + + def changeset(struct, attrs) do + cast(struct, attrs, [:min, :max]) + end +end diff --git a/mix.exs b/mix.exs index 31379b4..0df50dc 100644 --- a/mix.exs +++ b/mix.exs @@ -53,6 +53,7 @@ defmodule Onvif.MixProject do extras: ["README.md"], nest_modules_by_prefix: [ Onvif.Device, + Onvif.Schemas, Onvif.Devices, Onvif.Devices.Schemas, Onvif.Media.Ver10, @@ -64,7 +65,9 @@ defmodule Onvif.MixProject do Onvif.Replay, Onvif.Replay.Schemas, Onvif.Search, - Onvif.Search.Schemas + Onvif.Search.Schemas, + Onvif.PTZ, + Onvif.PTZ.Schemas ], groups_for_modules: [ Core: [ @@ -72,7 +75,8 @@ defmodule Onvif.MixProject do ~r/^Onvif.Discovery.*/, Onvif.Device, Onvif.MacAddress, - Onvif.Request + Onvif.Request, + ~r/Onvif.Schemas.*/ ], "Device Management": [ ~r/^Onvif.Devices.*/ @@ -91,6 +95,9 @@ defmodule Onvif.MixProject do ], Search: [ ~r/^Onvif.Search.*/ + ], + PTZ: [ + ~r/^Onvif.PTZ.*/ ] ] ] diff --git a/test/ptz/fixtures/get_nodes_success.xml b/test/ptz/fixtures/get_nodes_success.xml new file mode 100644 index 0000000..9e9754a --- /dev/null +++ b/test/ptz/fixtures/get_nodes_success.xml @@ -0,0 +1,100 @@ + + + + + + + PTZNode + + + http://www.onvif.org/ver10/tptz/PanTiltSpaces/PositionGenericSpace + + -1.000000 + 1.000000 + + + -1.000000 + 1.000000 + + + + http://www.onvif.org/ver10/tptz/ZoomSpaces/PositionGenericSpace + + 0.000000 + 1.000000 + + + + http://www.onvif.org/ver10/tptz/PanTiltSpaces/TranslationGenericSpace + + -1.000000 + 1.000000 + + + -1.000000 + 1.000000 + + + + http://www.onvif.org/ver10/tptz/ZoomSpaces/TranslationGenericSpace + + -1.000000 + 1.000000 + + + + http://www.onvif.org/ver10/tptz/PanTiltSpaces/VelocityGenericSpace + + -1.000000 + 1.000000 + + + -1.000000 + 1.000000 + + + + http://www.onvif.org/ver10/tptz/ZoomSpaces/VelocityGenericSpace + + -1.000000 + 1.000000 + + + + http://www.onvif.org/ver10/tptz/PanTiltSpaces/GenericSpeedSpace + + 0.000000 + 1.000000 + + + + http://www.onvif.org/ver10/tptz/ZoomSpaces/ZoomGenericSpeedSpace + + 0.000000 + 1.000000 + + + + 300 + true + focusout + focusin + autofocus + resetfocus + irisout + irisin + auto + lightoff + lighton + brushoff + brushon + + + 8 + Start + + + + + + diff --git a/test/ptz/get_nodes_test.exs b/test/ptz/get_nodes_test.exs new file mode 100644 index 0000000..16bbaaf --- /dev/null +++ b/test/ptz/get_nodes_test.exs @@ -0,0 +1,123 @@ +defmodule Onvif.PTZ.GetNodesTest do + use ExUnit.Case, async: true + + @moduletag capture_log: true + + alias Onvif.PTZ.Schemas.PTZNode + alias Onvif.Schemas.FloatRange + + describe "GetNodes/1" do + test "get ptz nodes" do + xml_response = File.read!("test/ptz/fixtures/get_nodes_success.xml") + + device = Onvif.Factory.device() + + Mimic.expect(Tesla, :request, fn _client, _opts -> + {:ok, %{status: 200, body: xml_response}} + end) + + {:ok, response} = Onvif.PTZ.GetNodes.request(device) + + assert response == [ + %PTZNode{ + token: "PTZNodeToken", + fixed_home_position: nil, + geo_move: nil, + name: "PTZNode", + supported_ptz_spaces: %PTZNode.SupportedPTZSpaces{ + absolute_pan_tilt_position_space: %Onvif.PTZ.Schemas.Space2DDescription{ + uri: "http://www.onvif.org/ver10/tptz/PanTiltSpaces/PositionGenericSpace", + x_range: %FloatRange{ + min: -1.0, + max: 1.0 + }, + y_range: %FloatRange{ + min: -1.0, + max: 1.0 + } + }, + absolute_zoom_position_space: %Onvif.PTZ.Schemas.Space1DDescription{ + uri: "http://www.onvif.org/ver10/tptz/ZoomSpaces/PositionGenericSpace", + x_range: %FloatRange{ + min: 0.0, + max: 1.0 + } + }, + relative_pan_tilt_translation_space: %Onvif.PTZ.Schemas.Space2DDescription{ + uri: "http://www.onvif.org/ver10/tptz/PanTiltSpaces/TranslationGenericSpace", + x_range: %FloatRange{ + min: -1.0, + max: 1.0 + }, + y_range: %FloatRange{ + min: -1.0, + max: 1.0 + } + }, + relative_zoom_translation_space: %Onvif.PTZ.Schemas.Space1DDescription{ + uri: "http://www.onvif.org/ver10/tptz/ZoomSpaces/TranslationGenericSpace", + x_range: %FloatRange{ + min: -1.0, + max: 1.0 + } + }, + continuous_pan_tilt_velocity_space: %Onvif.PTZ.Schemas.Space2DDescription{ + uri: "http://www.onvif.org/ver10/tptz/PanTiltSpaces/VelocityGenericSpace", + x_range: %FloatRange{ + min: -1.0, + max: 1.0 + }, + y_range: %FloatRange{ + min: -1.0, + max: 1.0 + } + }, + continuous_zoom_velocity_space: %Onvif.PTZ.Schemas.Space1DDescription{ + uri: "http://www.onvif.org/ver10/tptz/ZoomSpaces/VelocityGenericSpace", + x_range: %FloatRange{ + min: -1.0, + max: 1.0 + } + }, + pan_tilt_speed_space: %Onvif.PTZ.Schemas.Space1DDescription{ + uri: "http://www.onvif.org/ver10/tptz/PanTiltSpaces/GenericSpeedSpace", + x_range: %FloatRange{ + min: 0.0, + max: 1.0 + } + }, + zoom_speed_space: %Onvif.PTZ.Schemas.Space1DDescription{ + uri: "http://www.onvif.org/ver10/tptz/ZoomSpaces/ZoomGenericSpeedSpace", + x_range: %FloatRange{ + min: 0.0, + max: 1.0 + } + } + }, + maximum_number_of_presets: 300, + home_supported: true, + auxiliary_commands: [ + "focusout", + "focusin", + "autofocus", + "resetfocus", + "irisout", + "irisin", + "auto", + "lightoff", + "lighton", + "brushoff", + "brushon" + ], + extension: %PTZNode.Extension{ + supported_preset_tour: + %Onvif.PTZ.Schemas.PTZNode.Extension.SupportedPresetTour{ + maximum_number_of_preset_tours: 8, + ptz_preset_tour_operation: ["Start"] + } + } + } + ] + end + end +end