Skip to content

Commit f351420

Browse files
authored
PTZ: Get nodes (#114)
* PTZ: get nodes * add test * refactor schemas
1 parent 4ec0161 commit f351420

File tree

11 files changed

+611
-3
lines changed

11 files changed

+611
-3
lines changed

lib/device.ex

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ defmodule Onvif.Device do
3131
:recording_ver10_service_path,
3232
:replay_ver10_service_path,
3333
:search_ver10_service_path,
34+
:ptz_ver20_service_path,
3435
:auth_type,
3536
:time_diff_from_system_secs,
3637
:port,
@@ -57,6 +58,7 @@ defmodule Onvif.Device do
5758
field(:recording_ver10_service_path, :string)
5859
field(:replay_ver10_service_path, :string)
5960
field(:search_ver10_service_path, :string)
61+
field(:ptz_ver20_service_path, :string)
6062
embeds_one(:system_date_time, SystemDateAndTime)
6163
embeds_many(:services, Service)
6264

@@ -315,6 +317,7 @@ defmodule Onvif.Device do
315317
|> Map.put(:recording_ver10_service_path, get_recoding_ver10_service_path(device.services))
316318
|> Map.put(:replay_ver10_service_path, get_replay_ver10_service_path(device.services))
317319
|> Map.put(:search_ver10_service_path, get_search_ver10_service_path(device.services))
320+
|> Map.put(:ptz_ver20_service_path, get_ptz_ver20_service_path(device.services))
318321
end
319322

320323
defp get_media_ver20_service_path(services) do
@@ -351,4 +354,11 @@ defmodule Onvif.Device do
351354
%Service{} = service -> service.xaddr |> URI.parse() |> Map.get(:path)
352355
end
353356
end
357+
358+
defp get_ptz_ver20_service_path(services) do
359+
case Enum.find(services, &String.contains?(&1.namespace, "/ptz")) do
360+
nil -> nil
361+
%Service{} = service -> service.xaddr |> URI.parse() |> Map.get(:path)
362+
end
363+
end
354364
end

lib/factory.ex

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ defmodule Onvif.Factory do
1414
replay_ver10_service_path: "/onvif/replay_service",
1515
recording_ver10_service_path: "/onvif/recording_service",
1616
search_ver10_service_path: "/onvif/search_service",
17+
ptz_ver20_service_path: "/onvif/ptz_service",
1718
model: "N864A6",
1819
ntp: "NTP",
1920
password: "admin",
@@ -84,7 +85,12 @@ defmodule Onvif.Factory do
8485
namespace: "http://www.onvif.org/ver10/device/wsdl",
8586
version: "18.12",
8687
xaddr: "http://192.168.254.89/onvif/device_service"
87-
}
88+
},
89+
%Onvif.Devices.Schemas.Service{
90+
namespace: "http://www.onvif.org/ver20/ptz/wsdl",
91+
xaddr: "http://192.168.254.89/onvif/ptz_service",
92+
version: "22.12"
93+
},
8894
],
8995
time_diff_from_system_secs: 3597,
9096
username: "admin"

lib/ptz/get_nodes.ex

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
defmodule Onvif.PTZ.GetNodes do
2+
@moduledoc """
3+
Get the descriptions of the available PTZ Nodes.
4+
5+
A PTZ-capable device may have multiple PTZ Nodes. The PTZ Nodes may represent mechanical PTZ drivers, uploaded PTZ drivers or digital PTZ drivers.
6+
PTZ Nodes are the lowest level entities in the PTZ control API and reflect the supported PTZ capabilities.
7+
8+
The PTZ Node is referenced either by its name or by its reference token.
9+
"""
10+
11+
import SweetXml
12+
import XmlBuilder
13+
14+
require Logger
15+
16+
alias Onvif.PTZ.Schemas.PTZNode
17+
18+
def soap_action(), do: "http://www.onvif.org/ver20/ptz/wsdl/GetNodes"
19+
20+
@spec request(Device.t()) :: {:ok, [PTZNode.t()]} | {:error, map()}
21+
def request(device), do: Onvif.PTZ.request(device, __MODULE__)
22+
23+
def request_body(), do: element(:"s:Body", [:"tptz:GetNodes"])
24+
25+
def response(xml_response_body) do
26+
response =
27+
xml_response_body
28+
|> parse(namespace_conformant: true, quiet: true)
29+
|> xpath(
30+
~x"//s:Envelope/s:Body/tptz:GetNodesResponse/tptz:PTZNode"el
31+
|> add_namespace("s", "http://www.w3.org/2003/05/soap-envelope")
32+
|> add_namespace("tt", "http://www.onvif.org/ver10/schema")
33+
|> add_namespace("tptz", "http://www.onvif.org/ver20/ptz/wsdl")
34+
)
35+
|> Enum.map(&Onvif.PTZ.Schemas.PTZNode.parse/1)
36+
|> Enum.reduce([], fn raw_ptz_node, acc ->
37+
case Onvif.PTZ.Schemas.PTZNode.to_struct(raw_ptz_node) do
38+
{:ok, ptz_node} ->
39+
[ptz_node | acc]
40+
41+
{:error, changeset} ->
42+
Logger.error("Discarding invalid PTZ node: #{inspect(changeset)}")
43+
acc
44+
end
45+
end)
46+
47+
{:ok, Enum.reverse(response)}
48+
end
49+
end

lib/ptz/ptz.ex

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
defmodule Onvif.PTZ do
2+
@moduledoc """
3+
Interface for making requests to the Onvif PTZ(Pan/Tilt/Zoom) service
4+
5+
https://www.onvif.org/onvif/ver20/ptz/wsdl/ptz.wsdl
6+
"""
7+
require Logger
8+
9+
alias Onvif.Device
10+
11+
@namespaces [
12+
"xmlns:tt": "http://www.onvif.org/ver10/schema",
13+
"xmlns:tptz": "http://www.onvif.org/ver20/ptz/wsdl"
14+
]
15+
16+
@spec request(Device.t(), module()) :: {:ok, any} | {:error, map()}
17+
@spec request(Device.t(), list(), atom()) :: {:ok, any} | {:error, map()}
18+
def request(%Device{} = device, args \\ [], operation) do
19+
content = generate_content(operation, args)
20+
do_request(device, operation, content)
21+
end
22+
23+
defp do_request(device, operation, content) do
24+
device
25+
|> Onvif.API.client(service_path: :ptz_ver20_service_path)
26+
|> Tesla.request(
27+
method: :post,
28+
headers: [{"Content-Type", "application/soap+xml"}, {"SOAPAction", operation.soap_action()}],
29+
body: %Onvif.Request{content: content, namespaces: @namespaces}
30+
)
31+
|> parse_response(operation)
32+
end
33+
34+
defp generate_content(operation, []), do: operation.request_body()
35+
defp generate_content(operation, args), do: operation.request_body(args)
36+
37+
defp parse_response({:ok, %{status: 200, body: body}}, operation) do
38+
operation.response(body)
39+
end
40+
41+
defp parse_response({:ok, %{status: status_code, body: body}}, operation)
42+
when status_code >= 400,
43+
do:
44+
{:error,
45+
%{
46+
status: status_code,
47+
reason: "Received #{status_code} from #{operation}",
48+
response: body
49+
}}
50+
51+
defp parse_response({:error, response}, operation) do
52+
{:error, %{status: nil, reason: "Error performing #{operation}", response: response}}
53+
end
54+
end

lib/ptz/schemas/ptz_node.ex

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
defmodule Onvif.PTZ.Schemas.PTZNode do
2+
@moduledoc """
3+
Module describing a PTZ node.
4+
"""
5+
6+
use Ecto.Schema
7+
8+
import Ecto.Changeset
9+
import SweetXml
10+
11+
alias Onvif.PTZ.Schemas.{Space1DDescription, Space2DDescription}
12+
13+
@type t :: %__MODULE__{}
14+
15+
@primary_key false
16+
@derive Jason.Encoder
17+
embedded_schema do
18+
field(:token, :string)
19+
field(:fixed_home_position, :boolean)
20+
field(:geo_move, :boolean)
21+
field(:name, :string)
22+
23+
embeds_one :supported_ptz_spaces, SupportedPTZSpaces, primary_key: false do
24+
@derive Jason.Encoder
25+
embeds_one(:absolute_pan_tilt_position_space, Space2DDescription)
26+
embeds_one(:absolute_zoom_position_space, Space1DDescription)
27+
embeds_one(:relative_pan_tilt_translation_space, Space2DDescription)
28+
embeds_one(:relative_zoom_translation_space, Space1DDescription)
29+
embeds_one(:continuous_pan_tilt_velocity_space, Space2DDescription)
30+
embeds_one(:continuous_zoom_velocity_space, Space1DDescription)
31+
embeds_one(:pan_tilt_speed_space, Space1DDescription)
32+
embeds_one(:zoom_speed_space, Space1DDescription)
33+
end
34+
35+
field(:maximum_number_of_presets, :integer)
36+
field(:home_supported, :boolean)
37+
field(:auxiliary_commands, {:array, :string})
38+
39+
embeds_one :extension, Extension, primary_key: false do
40+
embeds_one :supported_preset_tour, SupportedPresetTour, primary_key: false do
41+
field(:maximum_number_of_preset_tours, :integer)
42+
field(:ptz_preset_tour_operation, {:array, :string})
43+
end
44+
end
45+
end
46+
47+
def to_struct(parsed) do
48+
%__MODULE__{}
49+
|> changeset(parsed)
50+
|> apply_action(:validate)
51+
end
52+
53+
@spec to_json(__MODULE__.t()) :: {:error, Jason.EncodeError.t() | Exception.t()} | {:ok, binary}
54+
def to_json(%__MODULE__{} = schema) do
55+
Jason.encode(schema)
56+
end
57+
58+
def parse(doc) do
59+
xmap(
60+
doc,
61+
token: ~x"./@token"s,
62+
fixed_home_position: ~x"./tt:FixedHomePosition/text()"s,
63+
geo_move: ~x"./tt:GeoMove/text()"s,
64+
name: ~x"./tt:Name/text()"s,
65+
supported_ptz_spaces:
66+
~x"./tt:SupportedPTZSpaces"e |> transform_by(&parse_supported_ptz_spaces/1),
67+
maximum_number_of_presets: ~x"./tt:MaximumNumberOfPresets/text()"s,
68+
home_supported: ~x"./tt:HomeSupported/text()"s,
69+
auxiliary_commands: ~x"./tt:AuxiliaryCommands/text()"sl,
70+
extension: ~x"./tt:Extension"e |> transform_by(&parse_extension/1)
71+
)
72+
end
73+
74+
def changeset(module, attrs) do
75+
module
76+
|> cast(attrs, __MODULE__.__schema__(:fields) -- [:supported_ptz_spaces, :extension])
77+
|> cast_embed(:supported_ptz_spaces, with: &supported_ptz_spaces_changeset/2)
78+
|> cast_embed(:extension, with: &extension_changeset/2)
79+
end
80+
81+
defp parse_supported_ptz_spaces(doc) do
82+
xmap(
83+
doc,
84+
absolute_pan_tilt_position_space:
85+
~x"./tt:AbsolutePanTiltPositionSpace"e |> transform_by(&Space2DDescription.parse/1),
86+
absolute_zoom_position_space:
87+
~x"./tt:AbsoluteZoomPositionSpace"e |> transform_by(&Space1DDescription.parse/1),
88+
relative_pan_tilt_translation_space:
89+
~x"./tt:RelativePanTiltTranslationSpace"e |> transform_by(&Space2DDescription.parse/1),
90+
relative_zoom_translation_space:
91+
~x"./tt:RelativeZoomTranslationSpace"e |> transform_by(&Space1DDescription.parse/1),
92+
continuous_pan_tilt_velocity_space:
93+
~x"./tt:ContinuousPanTiltVelocitySpace"e |> transform_by(&Space2DDescription.parse/1),
94+
continuous_zoom_velocity_space:
95+
~x"./tt:ContinuousZoomVelocitySpace"e |> transform_by(&Space1DDescription.parse/1),
96+
pan_tilt_speed_space:
97+
~x"./tt:PanTiltSpeedSpace"e |> transform_by(&Space1DDescription.parse/1),
98+
zoom_speed_space: ~x"./tt:ZoomSpeedSpace"e |> transform_by(&Space1DDescription.parse/1)
99+
)
100+
end
101+
102+
defp supported_ptz_spaces_changeset(module, attrs) do
103+
module
104+
|> cast(attrs, [])
105+
|> cast_embed(:absolute_pan_tilt_position_space, with: &Space2DDescription.changeset/2)
106+
|> cast_embed(:absolute_zoom_position_space, with: &Space1DDescription.changeset/2)
107+
|> cast_embed(:relative_pan_tilt_translation_space, with: &Space2DDescription.changeset/2)
108+
|> cast_embed(:relative_zoom_translation_space, with: &Space1DDescription.changeset/2)
109+
|> cast_embed(:continuous_pan_tilt_velocity_space, with: &Space2DDescription.changeset/2)
110+
|> cast_embed(:continuous_zoom_velocity_space, with: &Space1DDescription.changeset/2)
111+
|> cast_embed(:pan_tilt_speed_space, with: &Space1DDescription.changeset/2)
112+
|> cast_embed(:zoom_speed_space, with: &Space1DDescription.changeset/2)
113+
end
114+
115+
defp parse_extension(doc) do
116+
xmap(
117+
doc,
118+
supported_preset_tour:
119+
~x"./tt:SupportedPresetTour"e |> transform_by(&parse_supported_preset_tour/1)
120+
)
121+
end
122+
123+
defp parse_supported_preset_tour(doc) do
124+
xmap(
125+
doc,
126+
maximum_number_of_preset_tours: ~x"./tt:MaximumNumberOfPresetTours/text()"s,
127+
ptz_preset_tour_operation: ~x"./tt:PTZPresetTourOperation/text()"sl
128+
)
129+
end
130+
131+
defp extension_changeset(module, attrs) do
132+
module
133+
|> cast(attrs, [])
134+
|> cast_embed(:supported_preset_tour, with: &supported_preset_tour_changeset/2)
135+
end
136+
137+
defp supported_preset_tour_changeset(module, attrs) do
138+
cast(module, attrs, [:maximum_number_of_preset_tours, :ptz_preset_tour_operation])
139+
end
140+
end
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
defmodule Onvif.PTZ.Schemas.Space1DDescription do
2+
@moduledoc """
3+
Module describing a 1D space.
4+
"""
5+
6+
use Ecto.Schema
7+
8+
import Ecto.Changeset
9+
import SweetXml
10+
11+
alias Onvif.Schemas.FloatRange
12+
13+
@type t :: %__MODULE__{}
14+
15+
@primary_key false
16+
@derive Jason.Encoder
17+
embedded_schema do
18+
field(:uri, :string)
19+
embeds_one(:x_range, FloatRange)
20+
end
21+
22+
def parse(nil), do: nil
23+
24+
def parse(doc) do
25+
xmap(
26+
doc,
27+
uri: ~x"./tt:URI/text()"s,
28+
x_range: ~x"./tt:XRange"e |> transform_by(&FloatRange.parse/1)
29+
)
30+
end
31+
32+
def changeset(space1d_description, attrs) do
33+
space1d_description
34+
|> cast(attrs, [:uri])
35+
|> cast_embed(:x_range, with: &FloatRange.changeset/2)
36+
end
37+
end
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
defmodule Onvif.PTZ.Schemas.Space2DDescription do
2+
@moduledoc """
3+
Module describing a 2D space.
4+
"""
5+
6+
use Ecto.Schema
7+
8+
import Ecto.Changeset
9+
import SweetXml
10+
11+
alias Onvif.Schemas.FloatRange
12+
13+
@type t :: %__MODULE__{}
14+
15+
@primary_key false
16+
@derive Jason.Encoder
17+
embedded_schema do
18+
field(:uri, :string)
19+
20+
embeds_one(:x_range, FloatRange)
21+
embeds_one(:y_range, FloatRange)
22+
end
23+
24+
def parse(nil), do: nil
25+
26+
def parse(doc) do
27+
xmap(
28+
doc,
29+
uri: ~x"./tt:URI/text()"s,
30+
x_range: ~x"./tt:XRange"e |> transform_by(&FloatRange.parse/1),
31+
y_range: ~x"./tt:YRange"e |> transform_by(&FloatRange.parse/1)
32+
)
33+
end
34+
35+
def changeset(space2d_description, attrs) do
36+
space2d_description
37+
|> cast(attrs, [:uri])
38+
|> cast_embed(:x_range, with: &FloatRange.changeset/2)
39+
|> cast_embed(:y_range, with: &FloatRange.changeset/2)
40+
end
41+
end

0 commit comments

Comments
 (0)