mirror of
https://github.com/ZoneMinder/zoneminder.git
synced 2026-11-07 21:23:02 -05:00
The hand-rolled regex in open() mis-parsed a stream Path that carried no credentials, e.g. rtsp://10.0.0.4:554/cam: the greedy [^:@]+ consumed the host as the username, 554 as the password, and ADDRESS backtracked to the single digit 4. The control daemon then dialled http://...@4, producing "Can't connect to 4:80 (Connection timed out)". Replace the regex with the shared URI-based Control.pm guess_credentials(), which handles ControlAddress and Path, converts rtsp to http, and falls back to the Monitor User/Pass. Create the LWP::UserAgent before parsing since guess_credentials() sets credentials on it. The Grandstream login wire protocol (challenge/authcode and old-style fallback) is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
270 lines
9.2 KiB
Perl
270 lines
9.2 KiB
Perl
# ==========================================================================
|
|
#
|
|
# ZoneMinder GrandSteam Control Protocol Module
|
|
# Copyright (C) 2021 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.
|
|
#
|
|
# ==========================================================================
|
|
#
|
|
# This module contains the implementation of the Vivotek ePTZ camera control
|
|
# protocol
|
|
#
|
|
package ZoneMinder::Control::Grandstream;
|
|
|
|
use 5.006;
|
|
use strict;
|
|
use warnings;
|
|
|
|
require ZoneMinder::Base;
|
|
require ZoneMinder::Control;
|
|
|
|
our @ISA = qw(ZoneMinder::Control);
|
|
|
|
# ==========================================================================
|
|
#
|
|
# Vivotek ePTZ Control Protocol
|
|
#
|
|
# ==========================================================================
|
|
|
|
use ZoneMinder::Logger qw(:all);
|
|
use ZoneMinder::Config qw(:all);
|
|
use ZoneMinder::General qw(:all);
|
|
|
|
use Time::HiRes qw( usleep );
|
|
use URI::Encode qw(uri_encode);
|
|
use XML::LibXML;
|
|
use Digest::MD5 qw(md5 md5_hex md5_base64);
|
|
|
|
|
|
our $REALM = '';
|
|
our $PROTOCOL = 'https://';
|
|
our $USERNAME = 'admin';
|
|
our $PASSWORD = '';
|
|
our $ADDRESS = '';
|
|
our $BASE_URL = '';
|
|
|
|
my %config_types = (
|
|
upgrade => {
|
|
P6767 => { default_value=>1, desc=>'Firmware Upgrade Method http' },
|
|
P192 => { desc=>'Firmware Server Path' },
|
|
},
|
|
date => {
|
|
P64 => { desc=>'Timezone' },
|
|
P5006 => { default_value=>1, desc=>'Enable NTP' },
|
|
P30 => { desc=>'NTP Server', },
|
|
},
|
|
access => {
|
|
P12053 => {default_value=>1, desc=>'Enable UPnP Search' },
|
|
},
|
|
cmos => {
|
|
#P12314=> { value=>0, desc=>'Power Frequency' },
|
|
},
|
|
video => {
|
|
#P12306 => { value=>'26', desc=>'primary codec' },# 26: h264, 96: mjpeg, 98: h265
|
|
P12313 => { desc=>'primary profile' },# 0: baseline, 1: main, 2: high
|
|
P12307 => { desc=>'primary resolution' }, # 1025: 1920x1080 1023: 1280x960, 1022: 1280x720
|
|
P12904 => { desc=>'primary fps', }, # fps 5,10,15,20,25,30
|
|
P12311 => { desc=>'Image quality', }, # 0 very high, 4 very low
|
|
P12312 => { desc=>'Iframe interval', }, # i-frame interval 5-100
|
|
},
|
|
osd => {
|
|
P10044 => { default_value=> 1, desc=>'Display Time' },
|
|
#P10045 => { value=> 1, desc=>'Display Text' },
|
|
P10001 => { default_value=> 1, desc=>'OSD Date Format' },
|
|
#P10040 => { value=>'', desc=> 'OSD Text' },
|
|
},
|
|
audio => {
|
|
P14000 => { default_value=>1, desc=>'Audio codec' }, # 1,2
|
|
P14003 => { default_value=>0, desc=>'Audio out volume' }, # 0-6
|
|
P14002 => { default_value=>0, desc=>'Audio in volume' }, # 0-6
|
|
},
|
|
debug => {
|
|
P8042 => { default_value=>0, desc=>'Debug log protocol' }, # 0: UDP 1: SSL/TLS
|
|
P207 => { desc=>'Debug Log Server' },
|
|
P208 => { desc=>'Debug Log Level' },
|
|
},
|
|
);
|
|
|
|
sub open {
|
|
my $self = shift;
|
|
$self->loadMonitor();
|
|
|
|
use LWP::UserAgent;
|
|
$self->{ua} = LWP::UserAgent->new;
|
|
$self->{ua}->agent('ZoneMinder Control Agent/'.ZoneMinder::Base::ZM_VERSION);
|
|
$self->{ua}->ssl_opts(verify_hostname => 0, SSL_verify_mode => 0x00);
|
|
$self->{ua}->cookie_jar( {} );
|
|
|
|
# Use the shared URI-based parser (Control.pm) instead of a hand-rolled regex.
|
|
# The old regex mis-parsed stream Paths such as rtsp://host:554/path: the host
|
|
# was consumed as the username, the port as the password, and ADDRESS
|
|
# backtracked to a single digit, producing failures like "Can't connect to 4:80".
|
|
if (!$self->guess_credentials()) {
|
|
Error("Monitor $self->{Monitor}{Id} ($self->{Monitor}{Name}): Failed to parse connection details from ControlAddress '".($self->{Monitor}{ControlAddress} // '')."' or Path '".($self->{Monitor}{Path} // '')."'");
|
|
return 0;
|
|
}
|
|
$USERNAME = $self->{username} if $self->{username};
|
|
$PASSWORD = $self->{password} if defined $self->{password};
|
|
$ADDRESS = $self->{host};
|
|
# guess_credentials sets $self->{host} for ping. Keep the https:// default for
|
|
# the control API; the camera's HTTP/HTTPS port is independent of the stream port.
|
|
$BASE_URL = $PROTOCOL.$ADDRESS;
|
|
Debug("Monitor $self->{Monitor}{Id} ($self->{Monitor}{Name}): base url $BASE_URL user $USERNAME");
|
|
|
|
my $url = $BASE_URL.'/goform/login?cmd=login&type=0&user='.$USERNAME;
|
|
my $response = $self->get($url);
|
|
if ($response->is_success()) {
|
|
my $dom = XML::LibXML->load_xml(string => $response->content);
|
|
my $challengeString = $dom->getElementsByTagName('ChallengeCode')->string_value();
|
|
Debug("Monitor $self->{Monitor}{Id} ($self->{Monitor}{Name}): challengestring: ".$challengeString);
|
|
my $authcode = md5_hex($challengeString.':GSC36XXlZpRsFzCbM:'.$PASSWORD);
|
|
$url .= '&authcode='.$authcode;
|
|
$response = $self->get($url);
|
|
if (!$response->is_success()) {
|
|
Error("Monitor $self->{Monitor}{Id} ($self->{Monitor}{Name}): Grandstream login authcode request failed: ".$response->status_line());
|
|
return 0;
|
|
}
|
|
my $dom2 = XML::LibXML->load_xml(string => $response->content);
|
|
my $rescode = $dom2->getElementsByTagName('ResCode')->string_value();
|
|
Debug("Monitor $self->{Monitor}{Id} ($self->{Monitor}{Name}): Grandstream login ResCode: $rescode");
|
|
} else {
|
|
# Initial probe failed; fall back to the old basic-auth-in-URL style and
|
|
# test that too, so we only report success if we can actually reach the camera.
|
|
Warning("Monitor $self->{Monitor}{Id} ($self->{Monitor}{Name}): Falling back to old style");
|
|
$PROTOCOL = 'http://';
|
|
$BASE_URL = $PROTOCOL.$USERNAME.':'.$PASSWORD.'@'.$ADDRESS;
|
|
$response = $self->get($BASE_URL.'/goform/login?cmd=login&type=0&user='.$USERNAME);
|
|
if (!$response->is_success()) {
|
|
Error("Monitor $self->{Monitor}{Id} ($self->{Monitor}{Name}): Grandstream old-style connection failed: ".$response->status_line());
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
$self->{state} = 'open';
|
|
return 1;
|
|
}
|
|
|
|
sub get {
|
|
my $self = shift;
|
|
my $url = shift;
|
|
Debug("Monitor $self->{Monitor}{Id} ($self->{Monitor}{Name}): Getting $url");
|
|
my $response = $self->{ua}->get($url);
|
|
Debug("Monitor $self->{Monitor}{Id} ($self->{Monitor}{Name}): Response: ".$response->status_line.' '.$response->content);
|
|
return $response;
|
|
}
|
|
|
|
sub close {
|
|
my $self = shift;
|
|
$self->{state} = 'closed';
|
|
}
|
|
|
|
sub sendCmd {
|
|
my ($self, $cmd, $speedcmd) = @_;
|
|
|
|
$self->printMsg( $speedcmd, 'Tx' );
|
|
$self->printMsg( $cmd, 'Tx' );
|
|
|
|
my $req = HTTP::Request->new( GET => $BASE_URL."/cgi-bin/camctrl/eCamCtrl.cgi?stream=0&$speedcmd&$cmd");
|
|
my $res = $self->{ua}->request($req);
|
|
|
|
if (!$res->is_success) {
|
|
Error("Monitor $self->{Monitor}{Id} ($self->{Monitor}{Name}): Request failed: ".$res->status_line().' (URI: '.$req->as_string().')');
|
|
}
|
|
return $res->is_success;
|
|
}
|
|
|
|
sub get_config {
|
|
my $self = shift;
|
|
|
|
my %config;
|
|
foreach my $category ( @_ ? @_ : keys %config_types ) {
|
|
my $response = $self->get($BASE_URL.'/goform/config?cmd=get&type='.$category);
|
|
my $dom = XML::LibXML->load_xml(string => $response->content);
|
|
if (!$dom) {
|
|
Error("Monitor $self->{Monitor}{Id} ($self->{Monitor}{Name}): No document from :".$response->content());
|
|
return;
|
|
}
|
|
Debug("Monitor $self->{Monitor}{Id} ($self->{Monitor}{Name}): ".$dom->toString(1));
|
|
$config{$category} = {};
|
|
my $Configuration = $dom->getElementsByTagName('Configuration');
|
|
my $xml = $Configuration->get_node(0);
|
|
if (!$xml) {
|
|
Warning("Monitor $self->{Monitor}{Id} ($self->{Monitor}{Name}): Unable to get Configuration node from ".$response->content());
|
|
return \%config;
|
|
}
|
|
foreach my $node ($xml->childNodes()) {
|
|
$config{$category}{$node->nodeName} = {
|
|
value=>$node->textContent
|
|
};
|
|
}
|
|
} # end foreach category
|
|
return \%config;
|
|
} # end sub get_config
|
|
|
|
sub set_config {
|
|
my $self = shift;
|
|
my $updates = shift;
|
|
|
|
my $url = join('&', $BASE_URL.'/goform/config?cmd=set',
|
|
map { $_.'='.uri_encode(uri_encode($$updates{$_}{value}, { encode_reserved=>1} )) } keys %$updates );
|
|
my $response = $self->get($url);
|
|
return 0 if !$response->is_success();
|
|
return 0 if ($response->content !~ /Successful/i);
|
|
return 1;
|
|
}
|
|
|
|
sub reboot {
|
|
my $self = shift;
|
|
$self->get($BASE_URL.'/goform/config?cmd=reboot');
|
|
}
|
|
|
|
1;
|
|
__END__
|
|
|
|
=head1 NAME
|
|
|
|
ZoneMinder::Control::Grandstream - ZoneMinder Perl extension for Grandstream
|
|
camera control protocol
|
|
|
|
=head1 SYNOPSIS
|
|
|
|
use ZoneMinder::Control::Grandstream;
|
|
|
|
=head1 DESCRIPTION
|
|
|
|
This module implements the protocol used in various Grandstream IP cameras.
|
|
|
|
=head2 EXPORT
|
|
|
|
None.
|
|
|
|
=head1 SEE ALSO
|
|
|
|
I would say, see ZoneMinder::Control documentation. But it is a stub.
|
|
|
|
=head1 AUTHOR
|
|
|
|
Isaac Connor E<lt>isaac@zoneminder.comE<gt>
|
|
|
|
=head1 COPYRIGHT AND LICENSE
|
|
|
|
Copyright (C) 2021 by ZoneMinder Inc
|
|
|
|
Licensed under the GNU General Public License v2 or later; see the COPYING
|
|
file distributed with ZoneMinder for the full text.
|
|
|
|
=cut
|