Skip to content

Commit e8e39d2

Browse files
Merge remote-tracking branch 'upstream/master' into ai_server
Resolve conflicts from upstream PR ZoneMinder#4788 (AVPixelFormat migration / SHM cross-process format sync) against ai_server's dual-ring SHM architecture. Resolution approach (unify both): - SHM layout keeps ai_server's decoder + analysis image rings, each with its own per-slot AVPixelFormat array; folds in upstream's per-slot format-sync safety (shm_slot_size bound, aligned pixfmt array placement). - zm_monitor.{cpp,h}: add ReadAnalysisShmFrame() mirroring upstream's ReadShmFrame() for the analysis ring; GetAlarmImage() serves the analysis ring through it (range-checked). Take upstream's stride-aware CheckSignal and sentinel-before-bounds GetImage/getSnapshot. Drop the duplicated Monitor::Decode body (lives in zm_decoder_thread.cpp on this branch). - zm_decoder_thread.cpp: route the decode-path SHM write through WriteShmFrame (copy-then-publish per-slot format). - zm_monitorstream.cpp: keep ai_server pacing/ring selection; route reads through ReadShmFrame/ReadAnalysisShmFrame for format sync. - zm_image.cpp: keep ai_server version (its zm_image.h retains the u_buffer/v_buffer members used by YUV420 drawing that upstream removed). - zm_ffmpeg_camera.cpp: drop dead imagePixFormat assignments (upstream confirms the field is dead); keep ai_server's OpenFfmpeg-merged-into-PrimeCapture. - zm_monitor_onvif.cpp: union of ours (PullMessages auth-skip detection, alternate-auth retry, subscription leak cleanup) and theirs (SOAP socket timeout bounding within zmdc's 30s kill window); theirs' tighter pull_timeout clamp supersedes ours. - zm_camera.cpp, zm_mpeg.cpp, zm_pixformat.h, tests/zm_pixformat.cpp: take upstream (supersets / nullptr-safe pix fmt helper). - Control.pm: take upstream parse_ControlAddress port/address handling (consistent with get_realm and parse_Path). - MonitorStream.js: keep ai_server classify-fetch + backoff reconnect. - monitor.php: use upstream i18n translate('DeprecatedColoursSetting'). Builds clean (libzm + zmc/zma/zms/zmu/zm_rtsp_server). C++ unit tests not run (Catch2 unavailable locally). Upstream PR ZoneMinder#4788 zm_image.cpp hardening fixes were not ported piecemeal (different member layout); candidate follow-up cherry-picks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2 parents 40b5ed3 + dc7d89b commit e8e39d2

23 files changed

Lines changed: 1694 additions & 1138 deletions

File tree

db/zm_create.sql.in

Lines changed: 45 additions & 43 deletions
Large diffs are not rendered by default.

db/zm_update-1.39.16.sql

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
--
2+
-- Add a model-specific Controls entry for the LTS CMIP1342WE-28MDA.
3+
--
4+
-- This is a fixed ColorVu camera (no PTZ, no motorised focus/iris). Its white
5+
-- light is driven through the HikVision/LTS ISAPI supplement-light interface
6+
-- (ISAPI/Image/channels/1/supplementLight: colorVuWhiteLight/eventIntelligence/
7+
-- irLight/close), so only CanLight and CanReboot apply. CanReset stays 0 because
8+
-- the HikVision module implements reboot but no reset.
9+
--
10+
INSERT INTO `Controls`
11+
(`Name`,`Type`,`Protocol`,`CanReset`,`CanReboot`,`CanLight`)
12+
SELECT 'LTS CMIP1342WE-28MDA','Ffmpeg','HikVision',0,1,1
13+
FROM DUAL
14+
WHERE NOT EXISTS (SELECT 1 FROM `Controls` WHERE `Name`='LTS CMIP1342WE-28MDA');
15+
16+
--
17+
-- Same for the LTS CMIP3CD42WI-28AISP: another fixed ColorVu camera with the
18+
-- same white-light interface and no PTZ/focus/iris.
19+
--
20+
INSERT INTO `Controls`
21+
(`Name`,`Type`,`Protocol`,`CanReset`,`CanReboot`,`CanLight`)
22+
SELECT 'LTS CMIP3CD42WI-28AISP','Ffmpeg','HikVision',0,1,1
23+
FROM DUAL
24+
WHERE NOT EXISTS (SELECT 1 FROM `Controls` WHERE `Name`='LTS CMIP3CD42WI-28AISP');

scripts/ZoneMinder/lib/ZoneMinder/Control/HikVision.pm

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,157 @@ sub reboot {
437437

438438
$self->PutCmd('ISAPI/System/reboot');
439439
}
440+
#
441+
# Supplement / white light control.
442+
#
443+
# Hik/LTS models expose their illuminators via
444+
# ISAPI/Image/channels/<n>/supplementLight. ColorVu models (e.g. the LTS
445+
# CMIP1342WE-28MDA) advertise a colorVuWhiteLight mode; IR-only models advertise
446+
# only irLight/close. The set of modes a given camera supports is read from the
447+
# .../supplementLight/capabilities document.
448+
#
449+
# We GET the current supplementLight document, rewrite only <supplementLightMode>,
450+
# and PUT the whole document back. Sending the full document (rather than a
451+
# minimal one-field PUT) preserves the std-cgi namespace and the sibling
452+
# brightness/EventIntelligence fields the firmware rejects PUTs without.
453+
#
454+
# The helpers below take/return plain strings and lists so they are unit-testable
455+
# without a camera (see t/hikvision_light.t).
456+
#
457+
# Pick the mode for "light on": prefer a white light, fall back to IR.
458+
sub light_on_mode {
459+
my %has = map { $_ => 1 } @_;
460+
return 'colorVuWhiteLight' if $has{colorVuWhiteLight};
461+
return 'whiteLight' if $has{whiteLight};
462+
return 'irLight' if $has{irLight};
463+
return undef;
464+
}
465+
# Pick the mode for "light off": restore the camera's smart/auto default where it
466+
# has one so night IR keeps working, else plain IR, else fully close.
467+
sub light_off_mode {
468+
my %has = map { $_ => 1 } @_;
469+
return 'eventIntelligence' if $has{eventIntelligence};
470+
return 'irLight' if $has{irLight};
471+
return 'close';
472+
}
473+
# Current <supplementLightMode> value from a supplementLight document.
474+
sub light_mode_from_xml {
475+
my ($xml) = @_;
476+
return undef if !defined $xml;
477+
return $xml =~ m{<supplementLightMode\b[^>]*>\s*([^<\s]+)} ? $1 : undef;
478+
}
479+
# The opt="a,b,c" mode list from a supplementLight capabilities document.
480+
sub light_modes_from_caps {
481+
my ($xml) = @_;
482+
return () if !defined $xml;
483+
return $xml =~ m{<supplementLightMode\b[^>]*\bopt="([^"]*)"} ? split(/,/, $1) : ();
484+
}
485+
# Rewrite <supplementLightMode> to $mode, leaving namespace and siblings intact.
486+
sub light_apply_mode {
487+
my ($xml, $mode) = @_;
488+
$xml =~ s{(<supplementLightMode\b[^>]*>)\s*[^<]*(</supplementLightMode>)}{$1$mode$2};
489+
return $xml;
490+
}
491+
# Map the active mode to the toggle button state: "On" iff it is the on-mode.
492+
sub light_status_from {
493+
my ($current, @modes) = @_;
494+
return undef if !defined $current;
495+
my $on = light_on_mode(@modes);
496+
return (defined $on and $current eq $on) ? 'On' : 'Off';
497+
}
498+
499+
# GET with a one-shot re-auth retry. The control daemon calls open() once at
500+
# startup and then issues requests much later; by then the camera has often
501+
# dropped the kept-alive connection and the UserAgent gives up on the stale
502+
# digest token with a 401. Rebuilding the ua and retrying re-authenticates
503+
# (same workaround PutCmd/PutXML use for writes).
504+
sub GetWithRetry {
505+
my ($self, $url) = @_;
506+
my $r = $self->get($url);
507+
if ($r and !$r->is_success and $r->code == 401) {
508+
$self->{ua} = LWP::UserAgent->new();
509+
$self->{ua}->cookie_jar({});
510+
$self->{ua}->credentials("$$self{host}:$$self{port}", $$self{realm}, $$self{username}, $$self{password});
511+
$r = $self->get($url);
512+
}
513+
return $r;
514+
}
515+
516+
sub supplementLightModes {
517+
my $self = shift;
518+
my $r = $self->GetWithRetry("/ISAPI/Image/channels/$ChannelID/supplementLight/capabilities");
519+
return () if !$r or !$r->is_success;
520+
return light_modes_from_caps($r->content);
521+
}
522+
523+
sub supplementLightDoc {
524+
my $self = shift;
525+
my $r = $self->GetWithRetry("/ISAPI/Image/channels/$ChannelID/supplementLight");
526+
if (!$r or !$r->is_success) {
527+
Error('HikVision: supplementLight GET failed: '.($r ? $r->status_line : 'no response'));
528+
return undef;
529+
}
530+
return $r->content;
531+
}
532+
533+
# PUT a complete XML document (one that already carries its own <?xml?> prolog
534+
# and namespace), retrying once on the 401 the camera throws after dropping a
535+
# kept-alive connection (same workaround as PutCmd).
536+
sub PutXML {
537+
my ($self, $cmd, $content) = @_;
538+
if (!$cmd) {
539+
Error('No cmd specified in PutXML');
540+
return;
541+
}
542+
my $req = HTTP::Request->new(PUT => $self->{BaseURL}.'/'.$cmd);
543+
$req->content_type('application/xml; charset=UTF-8');
544+
$req->content($content);
545+
my $res = $self->{ua}->request($req);
546+
if (!$res->is_success and $res->code == 401) {
547+
$self->{ua} = LWP::UserAgent->new();
548+
$self->{ua}->cookie_jar({});
549+
$self->{ua}->credentials("$$self{host}:$$self{port}", $$self{realm}, $$self{username}, $$self{password});
550+
$res = $self->{ua}->request($req);
551+
}
552+
if (!$res->is_success) {
553+
Error('supplementLight PUT failed: '.$res->status_line.' '.$res->content);
554+
} else {
555+
Debug('supplementLight set: '.$res->content);
556+
}
557+
return $res;
558+
}
559+
560+
sub Light {
561+
my ($self, $on) = @_;
562+
my $doc = $self->supplementLightDoc();
563+
if (!defined $doc) {
564+
Error('HikVision: supplementLight not available on this model');
565+
return;
566+
}
567+
my @modes = $self->supplementLightModes();
568+
# If the capabilities document was unavailable, fall back to whatever the
569+
# current document reveals plus close, so we can still toggle.
570+
@modes = ((light_mode_from_xml($doc) // ()), 'close') if !@modes;
571+
my $mode = $on ? light_on_mode(@modes) : light_off_mode(@modes);
572+
if (!defined $mode) {
573+
Error('HikVision: no supplementLight mode available for '.($on ? 'on' : 'off'));
574+
return;
575+
}
576+
$self->PutXML("ISAPI/Image/channels/$ChannelID/supplementLight", light_apply_mode($doc, $mode));
577+
}
578+
sub lightOn { $_[0]->Light(1); }
579+
sub lightOff { $_[0]->Light(0); }
580+
581+
# Status-aware toggle support: returns { WhiteLight => 'On'|'Off'|undef } in the
582+
# same shape the web UI's updateLightButton() already consumes.
583+
sub lightStatus {
584+
my $self = shift;
585+
my $doc = $self->supplementLightDoc();
586+
return { WhiteLight => undef } if !defined $doc;
587+
my @modes = $self->supplementLightModes();
588+
@modes = (light_mode_from_xml($doc) // ()) if !@modes;
589+
return { WhiteLight => light_status_from(light_mode_from_xml($doc), @modes) };
590+
}
440591

441592
my %config_types = (
442593
'ISAPI/System/deviceInfo' => {

scripts/ZoneMinder/lib/ZoneMinder/Event.pm

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -917,22 +917,22 @@ sub recover_timestamps {
917917
} # end foreach capture jpg
918918
$ZoneMinder::Database::dbh->commit();
919919
} elsif ( @mp4_files ) {
920+
# No capture jpgs (e.g. an mp4 plus a snapshot.jpg). Probe the video for
921+
# its duration. Length is NOT NULL in the db, so we must always set it.
920922
my $file = $path.'/'.$mp4_files[0];
921-
( $file ) = $file =~ /^(.*)$/;
923+
( $file ) = $file =~ /^(.*)$/; # de-taint
922924

923925
my $first_timestamp = (stat($file))[9];
924926
$starttime = $first_timestamp if $first_timestamp < $starttime;
925-
my $output = `ffprobe $file 2>&1`;
926-
my ($duration) = $output =~ /Duration: [:\.0-9]+/gm;
927-
Debug("From mp4 have duration $duration, start: $first_timestamp");
928-
929-
my ( $h, $m, $s, $u );
930-
if ( $duration =~ m/(\d+):(\d+):(\d+)\.(\d+)/ ) {
931-
( $h, $m, $s, $u ) = ($1, $2, $3, $4 );
932-
Debug("( $h, $m, $s, $u ) from /^(\\d{2}):(\\d{2}):(\\d{2})\.(\\d+)/");
933-
}
934-
my $seconds = ($h*60*60)+($m*60)+$s;
935-
$Event->Length($seconds.'.'.$u);
927+
928+
my $seconds = mp4_duration($file);
929+
if ( !defined $seconds ) {
930+
Warning("Unable to determine duration of $file from ffprobe. Defaulting Length to 0.");
931+
$seconds = 0;
932+
}
933+
Debug("From mp4 have duration $seconds seconds, start: $first_timestamp");
934+
935+
$Event->Length(sprintf('%.2f', $seconds));
936936
$Event->StartDateTime( Date::Format::time2str('%Y-%m-%d %H:%M:%S', $first_timestamp) );
937937
$Event->EndDateTime( Date::Format::time2str('%Y-%m-%d %H:%M:%S', $first_timestamp+$seconds) );
938938
}
@@ -942,6 +942,27 @@ sub recover_timestamps {
942942
$Event->StartDateTime( Date::Format::time2str('%Y-%m-%d %H:%M:%S', $starttime) );
943943
}
944944

945+
# Return the duration of a video file in seconds (float), or undef if it
946+
# cannot be determined. $file must already be de-tainted by the caller.
947+
sub mp4_duration {
948+
my $file = shift;
949+
950+
# Preferred: ask ffprobe for the machine-readable duration in seconds.
951+
my $duration = `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 '$file' 2>/dev/null`;
952+
chomp $duration if defined $duration;
953+
if ( defined $duration and $duration =~ /^(\d+(?:\.\d+)?)$/ ) {
954+
return $1;
955+
}
956+
957+
# Fallback: parse the human-readable "Duration: HH:MM:SS.uu" line.
958+
my $output = `ffprobe '$file' 2>&1`;
959+
if ( $output =~ /Duration:\s*(\d+):(\d+):(\d+)\.(\d+)/ ) {
960+
return ($1*3600) + ($2*60) + $3 + "0.$4";
961+
}
962+
963+
return undef;
964+
} # end sub mp4_duration
965+
945966
sub guess_EndDateTime {
946967
my $event = shift;
947968
if (!$$event{EndDateTime}) {
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
use strict;
2+
use warnings;
3+
use Test::More tests => 16;
4+
5+
require_ok('ZoneMinder::Control::HikVision');
6+
7+
my $P = 'ZoneMinder::Control::HikVision';
8+
9+
# --- mode selection (pure, capability-driven) ---------------------------------
10+
11+
# ColorVu model: white light available -> "on" uses it, "off" restores smart auto.
12+
is($P->can('light_on_mode')->(qw(eventIntelligence colorVuWhiteLight irLight close)),
13+
'colorVuWhiteLight', 'on prefers colorVuWhiteLight when present');
14+
is($P->can('light_on_mode')->(qw(irLight close)),
15+
'irLight', 'on falls back to irLight on IR-only models');
16+
is($P->can('light_on_mode')->(qw(close)),
17+
undef, 'on is undef when the model exposes no illuminator');
18+
19+
is($P->can('light_off_mode')->(qw(eventIntelligence colorVuWhiteLight irLight close)),
20+
'eventIntelligence', 'off restores eventIntelligence so night IR keeps working');
21+
is($P->can('light_off_mode')->(qw(irLight close)),
22+
'irLight', 'off falls back to irLight when there is no smart mode');
23+
is($P->can('light_off_mode')->(qw(colorVuWhiteLight close)),
24+
'close', 'off falls back to close as a last resort');
25+
26+
# --- parsing real LTS/std-cgi camera XML --------------------------------------
27+
28+
my $doc = '<?xml version="1.0" encoding="UTF-8"?>'."\n"
29+
.'<SupplementLight version="2.0" xmlns="http://www.std-cgi.com/ver20/XMLSchema">'
30+
.'<supplementLightMode>eventIntelligence</supplementLightMode>'
31+
.'<mixedLightBrightnessRegulatMode>auto</mixedLightBrightnessRegulatMode>'
32+
.'<whiteLightBrightness>100</whiteLightBrightness>'
33+
.'</SupplementLight>';
34+
35+
is($P->can('light_mode_from_xml')->($doc), 'eventIntelligence',
36+
'reads the active supplementLightMode from a camera document');
37+
38+
my $caps = '<SupplementLight><supplementLightMode '
39+
.'opt="eventIntelligence,colorVuWhiteLight,irLight,close">eventIntelligence'
40+
.'</supplementLightMode></SupplementLight>';
41+
is_deeply([$P->can('light_modes_from_caps')->($caps)],
42+
[qw(eventIntelligence colorVuWhiteLight irLight close)],
43+
'reads the advertised mode list from a capabilities document');
44+
45+
# --- the GET-modify-PUT rewrite preserves everything but the mode -------------
46+
47+
my $white = $P->can('light_apply_mode')->($doc, 'colorVuWhiteLight');
48+
like($white, qr{<supplementLightMode>colorVuWhiteLight</supplementLightMode>},
49+
'mode is rewritten to the requested value');
50+
like($white, qr{xmlns="http://www\.std-cgi\.com/ver20/XMLSchema"},
51+
'namespace is preserved through the rewrite');
52+
like($white, qr{<whiteLightBrightness>100</whiteLightBrightness>},
53+
'sibling fields the firmware requires are preserved');
54+
55+
# --- toggle-button status mapping ---------------------------------------------
56+
57+
is($P->can('light_status_from')->('colorVuWhiteLight',
58+
qw(eventIntelligence colorVuWhiteLight irLight close)),
59+
'On', 'white light active reports On');
60+
is($P->can('light_status_from')->('eventIntelligence',
61+
qw(eventIntelligence colorVuWhiteLight irLight close)),
62+
'Off', 'smart default reports Off');
63+
is($P->can('light_status_from')->('irLight', qw(irLight close)),
64+
'On', 'IR active on an IR-only model reports On');
65+
is($P->can('light_status_from')->(undef, qw(irLight close)),
66+
undef, 'unknown current mode reports undef');

src/zm_monitor_onvif.cpp

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
#include "zm_utils.h"
2525

2626
#include <cstdint>
27+
#include <algorithm>
2728
#include <cstring>
2829

2930
#ifdef WITH_GSOAP
@@ -38,6 +39,18 @@ namespace {
3839
const int ONVIF_COOLDOWN_RESET_SECONDS = 300; // Reset retry_count after 5 minutes of failure
3940
const int ONVIF_DEFAULT_TIMESTAMP_VALIDITY = 60; // WS-Security timestamp validity in seconds
4041

42+
// SOAP socket-level timeout (connect/recv/send). zmdc sends SIGTERM and waits
43+
// 30s before SIGKILL, so every SOAP operation must be able to unblock within
44+
// that window or zmc gets killed before the polling thread can join. Kept
45+
// safely below 30s. Must exceed ONVIF_MAX_PULL_TIMEOUT_SECONDS so a normal
46+
// long-poll is not aborted prematurely by the recv timeout.
47+
const int ONVIF_SOAP_TIMEOUT_SECONDS = 25;
48+
// Upper bound for the ONVIF PullMessages long-poll. Strictly less than
49+
// ONVIF_SOAP_TIMEOUT_SECONDS so a quiet long-poll (camera holding the
50+
// connection open with no events) returns before the socket recv timeout, and
51+
// so a stuck PullMessages cannot keep zmc from terminating in time.
52+
const int ONVIF_MAX_PULL_TIMEOUT_SECONDS = 20;
53+
4154
// Format seconds as ISO 8601 duration string (e.g., 5 -> "PT5S")
4255
inline std::string FormatDurationSeconds(int seconds) {
4356
return "PT" + std::to_string(seconds) + "S";
@@ -106,11 +119,16 @@ ONVIF::ONVIF(Monitor *parent_) :
106119
{
107120
parse_onvif_options();
108121

109-
// Clamp pull_timeout_seconds to be less than renewal advance time
110-
if (pull_timeout_seconds >= ONVIF_RENEWAL_ADVANCE_SECONDS) {
111-
Warning("ONVIF: pull_timeout %ds must be less than renewal advance time (%ds). Adjusting.",
112-
pull_timeout_seconds, ONVIF_RENEWAL_ADVANCE_SECONDS);
113-
pull_timeout_seconds = ONVIF_RENEWAL_ADVANCE_SECONDS - 1;
122+
// Clamp pull_timeout_seconds. It must stay below the renewal advance time (so
123+
// we renew before the subscription lapses) and below the SOAP socket timeout
124+
// (so a quiet long-poll completes before the recv timeout aborts it, and so a
125+
// stuck PullMessages cannot block zmc termination past zmdc's 30s kill window).
126+
const int pull_timeout_max =
127+
std::min(ONVIF_RENEWAL_ADVANCE_SECONDS - 1, ONVIF_MAX_PULL_TIMEOUT_SECONDS);
128+
if (pull_timeout_seconds > pull_timeout_max) {
129+
Warning("ONVIF: pull_timeout %ds too large; clamping to %ds to stay within renewal and termination limits.",
130+
pull_timeout_seconds, pull_timeout_max);
131+
pull_timeout_seconds = pull_timeout_max;
114132
}
115133

116134
// Build endpoint URL before initializing soap context (InitSoapContext needs it)
@@ -250,9 +268,13 @@ bool ONVIF::InitSoapContext() {
250268
return false;
251269
}
252270

253-
soap->connect_timeout = 0;
254-
soap->recv_timeout = 0;
255-
soap->send_timeout = 0;
271+
// Bound socket operations so a hung camera connection cannot block the polling
272+
// thread indefinitely. Kept below zmdc's 30s SIGTERM->SIGKILL window so zmc can
273+
// always terminate in time. Must exceed pull_timeout_seconds so a normal
274+
// long-poll is not aborted prematurely (see pull_timeout clamp in constructor).
275+
soap->connect_timeout = ONVIF_SOAP_TIMEOUT_SECONDS;
276+
soap->recv_timeout = ONVIF_SOAP_TIMEOUT_SECONDS;
277+
soap->send_timeout = ONVIF_SOAP_TIMEOUT_SECONDS;
256278
soap_register_plugin(soap, soap_wsse);
257279
soap_register_plugin(soap, soap_wsa);
258280

version.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.39.15
1+
1.39.16

0 commit comments

Comments
 (0)