Skip to content

PTZ: Get nodes #114

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Apr 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions lib/device.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
8 changes: 7 additions & 1 deletion lib/factory.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
49 changes: 49 additions & 0 deletions lib/ptz/get_nodes.ex
Original file line number Diff line number Diff line change
@@ -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
54 changes: 54 additions & 0 deletions lib/ptz/ptz.ex
Original file line number Diff line number Diff line change
@@ -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
140 changes: 140 additions & 0 deletions lib/ptz/schemas/ptz_node.ex
Original file line number Diff line number Diff line change
@@ -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
37 changes: 37 additions & 0 deletions lib/ptz/schemas/space1d_description.ex
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions lib/ptz/schemas/space2d_description.ex
Original file line number Diff line number Diff line change
@@ -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
Loading