diff --git a/scripts/ZoneMinder/lib/ZoneMinder/Control/ONVIF.pm b/scripts/ZoneMinder/lib/ZoneMinder/Control/ONVIF.pm new file mode 100644 index 000000000..23aa683ba --- /dev/null +++ b/scripts/ZoneMinder/lib/ZoneMinder/Control/ONVIF.pm @@ -0,0 +1,618 @@ +# ========================================================================== +# +# ZoneMinder Unified ONVIF Control Protocol Module +# Copyright (C) 2001-2024 ZoneMinder Inc +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# ========================================================================== +# +# Unified ONVIF SOAP/PTZ control module. Replaces the four earlier +# per-vendor copies (onvif.pm, Reolink.pm, Netcat.pm, TapoC520WS_ONVIF.pm) +# with a single implementation that: +# - Keeps all state in instance variables (no package globals) +# - Uses base-class guess_credentials() as a fallback +# - Supports SSL with automatic verification fallback +# - Fixes brightness case-sensitivity bug in imaging commands +# - Fixes missing sendCmd calls in contrast commands +# - Sends imaging commands to the correct /onvif/imaging endpoint +# +# Configuration (Monitor -> Control tab): +# Control Type : ONVIF +# Control Device : profile token, e.g. "prof0" (default "000") +# Control Address : [scheme://][user:pass@]host[:port][/onvif/path] +# Minimum: 192.168.1.1 +# Full: https://admin:secret@192.168.1.100:8080/onvif/PTZ +# AutoStopTimeout : duration in microseconds (1000000 = 1 s) +# +package ZoneMinder::Control::ONVIF; + +use 5.006; +use strict; +use warnings; + +require ZoneMinder::Base; +require ZoneMinder::Control; + +our @ISA = qw(ZoneMinder::Control); + +use ZoneMinder::Logger qw(:all); + +use Time::HiRes qw( usleep ); +use LWP::UserAgent; +use MIME::Base64; +use Digest::SHA; +use DateTime; +use URI; +use URI::Escape; +use IO::Socket::SSL; + +# ========================================================================= +# open / close +# ========================================================================= + +sub open { + my $self = shift; + $self->loadMonitor(); + + # --- UserAgent with SSL verification (will fall back if needed) -------- + $self->{ua} = LWP::UserAgent->new(); + $self->{ua}->agent('ZoneMinder Control Agent/'.ZoneMinder::Base::ZM_VERSION); + $self->{ua}->ssl_opts( + verify_hostname => 1, + SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_PEER, + ); + $$self{ssl_verified} = 1; + + # --- Profile token from ControlDevice (default '000') ----------------- + my $cd = $self->{Monitor}->{ControlDevice} // ''; + $$self{profileToken} = ($cd =~ /\S/) ? $cd : '000'; + $$self{realm} = $cd; + + # --- Parse ControlAddress for credentials, host, port, ONVIF path ----- + my $control_address = $self->{Monitor}->{ControlAddress} // ''; + if ($control_address =~ /\S/) { + $self->_parse_control_address($control_address); + } else { + # No ControlAddress — try base-class fallback (extracts creds from Path) + $$self{onvif_path} = '/onvif/PTZ'; + if ($self->guess_credentials()) { + # Rebuild BaseURL without any path that guess_credentials may include + my $scheme = ($$self{uri} && $$self{uri}->scheme()) ? $$self{uri}->scheme() : 'http'; + $scheme = 'http' if $scheme eq 'rtsp'; + $$self{BaseURL} = $scheme . '://' . $$self{host} . ':' . $$self{port}; + } else { + Warning('Failed to determine camera address from ControlAddress or Path'); + } + } + + # --- Connectivity check (non-fatal) ------------------------------------ + if ($$self{BaseURL}) { + my $res = $self->sendCmd('/onvif/device_service', + '', + 'http://www.onvif.org/ver10/device/wsdl/GetSystemDateAndTime'); + if ($res) { + Debug('ONVIF device responded at ' . $$self{BaseURL}); + } else { + Warning('No response from ONVIF device at ' . $$self{BaseURL} + . '/onvif/device_service — PTZ commands may still work'); + } + } + + $self->{state} = 'open'; + return !undef; +} + +sub _parse_control_address { + my ($self, $address) = @_; + + # Ensure a scheme is present so URI can parse correctly + $address = 'http://' . $address if $address !~ m{^https?://}; + + my $uri = URI->new($address); + + $$self{host} = $uri->host(); + + # Credentials + if ($uri->userinfo) { + my ($user, $pass) = split /:/, $uri->userinfo, 2; + $$self{username} = $user; + $$self{password} = URI::Escape::uri_unescape($pass) if defined $pass; + } + + # Port — URI->port falls back to default_port for the scheme + $$self{port} = $uri->port || 80; + + # ONVIF service path (default /onvif/PTZ) + my $path = $uri->path; + $$self{onvif_path} = ($path && $path ne '/') ? $path : '/onvif/PTZ'; + + # Base URL without path + $$self{BaseURL} = $uri->scheme . '://' . $$self{host} . ':' . $$self{port}; + + Debug('ONVIF open: base=' . $$self{BaseURL} + . ' path=' . $$self{onvif_path} + . ' user=' . ($$self{username} // '(none)')); +} + +# ========================================================================= +# WS-Security PasswordDigest helpers (plain functions, not methods) +# ========================================================================= + +sub _digest_base64 { + my ($nonce, $date, $password) = @_; + my $sha = Digest::SHA->new(1); + $sha->add($nonce . $date . $password); + return encode_base64($sha->digest, ''); +} + +sub _auth_header { + my ($username, $password) = @_; + return '' if !$username; + + my @charset = ('0' .. '9', 'A' .. 'Z', 'a' .. 'z'); + my $nonce = join '' => map $charset[rand @charset], 1 .. 20; + my $nonceBase64 = encode_base64($nonce, ''); + my $date = DateTime->now()->iso8601() . 'Z'; + + return ' + + + + ' . $username . ' + ' . _digest_base64($nonce, $date, $password) . ' + ' . $nonceBase64 . ' + ' . $date . ' + + +'; +} + +# ========================================================================= +# sendCmd — core SOAP POST +# ========================================================================= + +sub sendCmd { + my ($self, $endpoint, $body, $action) = @_; + + my $msg = '' + . _auth_header($$self{username}, $$self{password}) + . $body + . ''; + + my $url = $$self{BaseURL} . $endpoint; + $self->printMsg($url, 'Tx'); + + my $req = HTTP::Request->new(POST => $url); + $req->header('content-type' => 'application/soap+xml; charset=utf-8; action="' . $action . '"'); + $req->header('Host' => $$self{host} . ':' . $$self{port}); + $req->header('content-length' => length($msg)); + $req->header('accept-encoding' => 'gzip, deflate'); + $req->header('connection' => 'Close'); + $req->content($msg); + + my $res = $self->{ua}->request($req); + + # SSL fallback: on certificate errors, retry without verification + if (!$res->is_success && $$self{ssl_verified} + && $res->status_line =~ /SSL|certificate|verify/i) { + Warning("SSL verification failed for $url (" + . $res->status_line . '), retrying without verification'); + $self->{ua}->ssl_opts( + verify_hostname => 0, + SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_NONE, + SSL_hostname => '', + ); + $$self{ssl_verified} = 0; + $res = $self->{ua}->request($req); + } + + if ($res->is_success) { + return $res; + } + + Error("ONVIF command to $url failed: " . $res->status_line()); + return undef; +} + +# ========================================================================= +# Imaging — Brightness / Contrast +# ========================================================================= + +sub getCamParams { + my $self = shift; + $$self{CamParams} = {} if !$$self{CamParams}; + + my $body = ' + + + 000 + +'; + + my $res = $self->sendCmd('/onvif/imaging', $body, + 'http://www.onvif.org/ver20/imaging/wsdl/GetImagingSettings'); + + if ($res) { + my $content = $res->decoded_content; + if ($content =~ /(.+?)<\/tt:Brightness>/) { + $$self{CamParams}{$1} = $2; + } + if ($content =~ /(.+?)<\/tt:Contrast>/) { + $$self{CamParams}{$1} = $2; + } + } else { + Error('Unable to retrieve camera imaging settings'); + } +} + +sub _setImaging { + my ($self, $param, $value) = @_; + + my $body = ' + + + 000 + + <' . $param . ' xmlns="http://www.onvif.org/ver10/schema">' . $value . ' + + true + +'; + + return $self->sendCmd('/onvif/imaging', $body, + 'http://www.onvif.org/ver20/imaging/wsdl/SetImagingSettings'); +} + +# Increase Brightness (mapped to Iris Open in ZM UI) +sub irisAbsOpen { + my $self = shift; + my $params = shift; + $$self{CamParams} = {} if !$$self{CamParams}; + $self->getCamParams() unless $$self{CamParams}{Brightness}; + my $step = $self->getParam($params, 'step'); + + $$self{CamParams}{Brightness} += $step; + $$self{CamParams}{Brightness} = 100 if $$self{CamParams}{Brightness} > 100; + Debug("Brightness increase to $$self{CamParams}{Brightness}"); + $self->_setImaging('Brightness', $$self{CamParams}{Brightness}); +} + +# Decrease Brightness (mapped to Iris Close in ZM UI) +sub irisAbsClose { + my $self = shift; + my $params = shift; + $$self{CamParams} = {} if !$$self{CamParams}; + # BUG FIX: originals checked lowercase 'brightness' — never matched + $self->getCamParams() unless $$self{CamParams}{Brightness}; + my $step = $self->getParam($params, 'step'); + + $$self{CamParams}{Brightness} -= $step; + $$self{CamParams}{Brightness} = 0 if $$self{CamParams}{Brightness} < 0; + Debug("Brightness decrease to $$self{CamParams}{Brightness}"); + $self->_setImaging('Brightness', $$self{CamParams}{Brightness}); +} + +# Increase Contrast (mapped to White In in ZM UI) +sub whiteAbsIn { + my $self = shift; + my $params = shift; + $$self{CamParams} = {} if !$$self{CamParams}; + $self->getCamParams() unless $$self{CamParams}{Contrast}; + my $step = $self->getParam($params, 'step'); + + $$self{CamParams}{Contrast} += $step; + $$self{CamParams}{Contrast} = 100 if $$self{CamParams}{Contrast} > 100; + Debug("Contrast increase to $$self{CamParams}{Contrast}"); + # BUG FIX: originals (Reolink/Netcat/TapoC520WS) were missing this sendCmd call + $self->_setImaging('Contrast', $$self{CamParams}{Contrast}); +} + +# Decrease Contrast (mapped to White Out in ZM UI) +sub whiteAbsOut { + my $self = shift; + my $params = shift; + $$self{CamParams} = {} if !$$self{CamParams}; + $self->getCamParams() unless $$self{CamParams}{Contrast}; + my $step = $self->getParam($params, 'step'); + + $$self{CamParams}{Contrast} -= $step; + $$self{CamParams}{Contrast} = 0 if $$self{CamParams}{Contrast} < 0; + Debug("Contrast decrease to $$self{CamParams}{Contrast}"); + # BUG FIX: originals (Reolink/Netcat/TapoC520WS) were missing this sendCmd call + $self->_setImaging('Contrast', $$self{CamParams}{Contrast}); +} + +# ========================================================================= +# PTZ — ContinuousMove / Stop +# ========================================================================= + +sub _continuous_move { + my ($self, $pan_x, $pan_y, $zoom_x) = @_; + + my $velocity = ''; + if (defined $pan_x and defined $pan_y) { + $velocity .= ''; + } + if (defined $zoom_x) { + $velocity .= ''; + } + + my $body = ' + + + ' . $$self{profileToken} . ' + ' . $velocity . ' + +'; + + $self->sendCmd($$self{onvif_path}, $body, + 'http://www.onvif.org/ver20/ptz/wsdl/ContinuousMove'); + + my $is_zoom = (defined $zoom_x && !defined $pan_x); + $self->_auto_stop($is_zoom); +} + +sub _auto_stop { + my ($self, $is_zoom) = @_; + my $timeout = $self->{Monitor}->{AutoStopTimeout}; + return unless $timeout; + + Debug("Auto stop after ${timeout} us"); + usleep($timeout); + + my $body = ' + + + ' . $$self{profileToken} . ' + ' . ($is_zoom ? 'false' : 'true') . ' + ' . ($is_zoom ? 'true' : 'false') . ' + +'; + + $self->sendCmd($$self{onvif_path}, $body, + 'http://www.onvif.org/ver20/ptz/wsdl/ContinuousMove'); +} + +# --- Directional continuous moves ---------------------------------------- + +sub moveConUp { Debug('Move Up'); $_[0]->_continuous_move( 0, 0.5, undef) } +sub moveConDown { Debug('Move Down'); $_[0]->_continuous_move( 0, -0.5, undef) } +sub moveConLeft { Debug('Move Left'); $_[0]->_continuous_move(-0.49, 0, undef) } +sub moveConRight { Debug('Move Right'); $_[0]->_continuous_move( 0.49, 0, undef) } +sub moveConUpRight { Debug('Move Up-Right'); $_[0]->_continuous_move( 0.5, 0.5, undef) } +sub moveConUpLeft { Debug('Move Up-Left'); $_[0]->_continuous_move(-0.5, 0.5, undef) } +sub moveConDownRight { Debug('Move Down-Right'); $_[0]->_continuous_move( 0.5, -0.5, undef) } +sub moveConDownLeft { Debug('Move Down-Left'); $_[0]->_continuous_move(-0.5, -0.5, undef) } + +# --- Zoom ---------------------------------------------------------------- + +sub zoomConTele { Debug('Zoom Tele'); $_[0]->_continuous_move(undef, undef, 0.49) } +sub zoomConWide { Debug('Zoom Wide'); $_[0]->_continuous_move(undef, undef, -0.49) } + +# --- Stop ---------------------------------------------------------------- + +sub moveStop { + my $self = shift; + Debug('Move Stop'); + + my $body = ' + + + ' . $$self{profileToken} . ' + true + true + +'; + + $self->sendCmd($$self{onvif_path}, $body, + 'http://www.onvif.org/ver20/ptz/wsdl/ContinuousMove'); +} + +sub zoomStop { + my $self = shift; + Debug('Zoom Stop'); + + my $body = ' + + + ' . $$self{profileToken} . ' + false + true + +'; + + $self->sendCmd($$self{onvif_path}, $body, + 'http://www.onvif.org/ver20/ptz/wsdl/ContinuousMove'); +} + +# ========================================================================= +# AbsoluteMove / RelativeMove +# ========================================================================= + +sub moveMap { + my $self = shift; + my $params = shift; + my $x = $self->getParam($params, 'xcoord'); + my $y = $self->getParam($params, 'ycoord'); + Debug("AbsoluteMove to $x, $y"); + + my $body = ' + + + ' . $$self{profileToken} . ' + + + + + + + +'; + + $self->sendCmd($$self{onvif_path}, $body, + 'http://www.onvif.org/ver20/ptz/wsdl/AbsoluteMove'); +} + +sub moveRel { + my $self = shift; + my $params = shift; + my $x = $self->getParam($params, 'xcoord'); + my $y = $self->getParam($params, 'ycoord'); + Debug("RelativeMove by $x, $y"); + + my $body = ' + + + ' . $$self{profileToken} . ' + + + + + +'; + + $self->sendCmd($$self{onvif_path}, $body, + 'http://www.onvif.org/ver20/ptz/wsdl/RelativeMove'); +} + +# ========================================================================= +# Presets +# ========================================================================= + +sub presetSet { + my $self = shift; + my $params = shift; + my $preset = $self->getParam($params, 'preset'); + Debug("Set Preset $preset"); + + my $body = ' + + + ' . $$self{profileToken} . ' + ' . $preset . ' + +'; + + $self->sendCmd($$self{onvif_path}, $body, + 'http://www.onvif.org/ver20/ptz/wsdl/SetPreset'); +} + +sub presetGoto { + my $self = shift; + my $params = shift; + my $preset = $self->getParam($params, 'preset'); + Debug("Goto Preset $preset"); + + my $body = ' + + + ' . $$self{profileToken} . ' + ' . $preset . ' + +'; + + $self->sendCmd($$self{onvif_path}, $body, + 'http://www.onvif.org/ver20/ptz/wsdl/GotoPreset'); +} + +sub presetHome { + my $self = shift; + Debug('Goto Home Position'); + + my $body = ' + + + ' . $$self{profileToken} . ' + +'; + + $self->sendCmd($$self{onvif_path}, $body, + 'http://www.onvif.org/ver20/ptz/wsdl/GotoHomePosition'); +} + +# ========================================================================= +# Reboot +# ========================================================================= + +sub reboot { + my $self = shift; + Debug('ONVIF SystemReboot'); + + my $body = ' + + +'; + + $self->sendCmd('/onvif/device_service', $body, + 'http://www.onvif.org/ver10/device/wsdl/SystemReboot'); +} + +sub reset { + return $_[0]->reboot(); +} + +# ========================================================================= +# probe / rtsp_url (called by ZM's network probe infrastructure) +# ========================================================================= + +sub probe { + my ($ip, $username, $password) = @_; + + my $self = ZoneMinder::Control::ONVIF->new(); + $self->{ua} = LWP::UserAgent->new(); + $self->{ua}->agent('ZoneMinder Control Agent/'.ZoneMinder::Base::ZM_VERSION); + $self->{ua}->ssl_opts( + verify_hostname => 1, + SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_PEER, + ); + $$self{ssl_verified} = 1; + $$self{username} = $username; + $$self{password} = $password; + $$self{onvif_path} = '/onvif/PTZ'; + + my $test_body = ''; + my $test_action = 'http://www.onvif.org/ver10/device/wsdl/GetSystemDateAndTime'; + + foreach my $port ('80', '443') { + $$self{host} = $ip; + $$self{port} = $port; + + # Try HTTP + $$self{BaseURL} = "http://$ip:$port"; + if ($self->sendCmd('/onvif/device_service', $test_body, $test_action)) { + return { url => "rtsp://$ip/onvif1", realm => '' }; + } + + # Try HTTPS on 443 + if ($port eq '443') { + $$self{BaseURL} = "https://$ip:$port"; + if ($self->sendCmd('/onvif/device_service', $test_body, $test_action)) { + return { url => "rtsp://$ip/onvif1", realm => '' }; + } + } + } + return undef; +} + +sub rtsp_url { + my ($self, $ip) = @_; + return 'rtsp://' . $ip . '/onvif1'; +} + +1; +__END__