feat: add Indicator LED control capability with session keepalive refs #4875

Add a CanIndicatorLight capability and status-aware Indicator toggle button. The indicator LED on the ASH21-B, ADC2W and ASH42-B is controlled via the LightGlobal config (configManager get/setConfig); add indicatorLightOn/Off/Status to Dahua_RPC and a model-specific 'Amcrest ASH42-B RPC' Controls row, with the capability also enabled on the ASH21-B/ADC2W/generic rows. Migration zm_update-1.39.13.sql adds the column.

Add Dahua_RPC keepAlive (global.keepAlive) wired into a 30s zmcontrol idle tick, plus session-expiry re-login retry in set_config and the status queries, so the long-running control daemon does not silently fail after the ~60s session timeout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Isaac Connor
2026-06-03 20:00:00 -04:00
parent fd83672a11
commit f02bc29c0d
12 changed files with 153 additions and 7 deletions

View File

@@ -132,6 +132,7 @@ CREATE TABLE `Controls` (
`MinWhiteSpeed` int(10) unsigned default NULL,
`MaxWhiteSpeed` int(10) unsigned default NULL,
`CanLight` tinyint(3) unsigned NOT NULL default '0',
`CanIndicatorLight` tinyint(3) unsigned NOT NULL default '0',
`HasPresets` tinyint(3) unsigned NOT NULL default '0',
`NumPresets` tinyint(3) unsigned NOT NULL default '0',
`HasHomePreset` tinyint(3) unsigned NOT NULL default '0',
@@ -1212,9 +1213,10 @@ INSERT INTO `Controls` VALUES (NULL,'PSIA','Remote','PSIA',0,0,0,0,1,0,0,1,0,0,0
INSERT INTO `Controls` VALUES (NULL,'Dahua','Ffmpeg','Dahua',0,0,1,1,1,0,0,1,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,20,1,1,1,1,0,0,1,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0);
INSERT INTO `Controls` VALUES (NULL,'FOSCAMR2C','Libvlc','FOSCAMR2C',1,1,1,0,0,0,0,0,0,NULL,NULL,NULL,NULL,0,NULL,NULL,0,0,0,0,0,NULL,NULL,NULL,NULL,0,NULL,NULL,0,0,0,0,0,NULL,NULL,NULL,NULL,0,NULL,NULL,0,0,0,0,0,NULL,NULL,NULL,NULL,0,NULL,NULL,0,0,0,0,0,NULL,NULL,NULL,NULL,0,NULL,NULL,1,12,0,1,1,1,0,0,0,1,1,NULL,NULL,NULL,NULL,1,0,4,0,NULL,1,NULL,NULL,NULL,NULL,1,0,4,0,NULL,0,0);
INSERT INTO `Controls` VALUES (NULL,'Amcrest HTTP API','Ffmpeg','Amcrest_HTTP',0,0,1,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,5,0,0,1,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,5);
INSERT INTO `Controls` (`Name`,`Type`,`Protocol`,`CanReset`,`CanReboot`,`CanZoom`,`CanZoomCon`,`HasPresets`,`NumPresets`,`HasHomePreset`,`CanSetPresets`,`CanMove`,`CanMoveDiag`,`CanMoveCon`,`CanPan`,`CanTilt`) VALUES ('Dahua/Amcrest RPC','Ffmpeg','Dahua_RPC',1,1,1,1,1,25,1,1,1,1,1,1,1);
INSERT INTO `Controls` (`Name`,`Type`,`Protocol`,`CanReset`,`CanReboot`,`CanZoom`,`CanZoomCon`,`HasPresets`,`NumPresets`,`HasHomePreset`,`CanSetPresets`,`CanMove`,`CanMoveDiag`,`CanMoveCon`,`CanPan`,`CanTilt`) VALUES ('Amcrest ASH21-B RPC','Ffmpeg','Dahua_RPC',1,1,0,0,0,0,0,0,1,0,1,1,1);
INSERT INTO `Controls` (`Name`,`Type`,`Protocol`,`CanReset`,`CanReboot`,`CanZoom`,`CanZoomCon`,`HasPresets`,`NumPresets`,`HasHomePreset`,`CanSetPresets`,`CanMove`,`CanMoveDiag`,`CanMoveCon`,`CanPan`,`CanTilt`,`CanLight`) VALUES ('Amcrest ADC2W RPC','Ffmpeg','Dahua_RPC',1,1,0,0,0,0,0,0,0,0,0,0,0,1);
INSERT INTO `Controls` (`Name`,`Type`,`Protocol`,`CanReset`,`CanReboot`,`CanZoom`,`CanZoomCon`,`HasPresets`,`NumPresets`,`HasHomePreset`,`CanSetPresets`,`CanMove`,`CanMoveDiag`,`CanMoveCon`,`CanPan`,`CanTilt`,`CanLight`,`CanIndicatorLight`) VALUES ('Dahua/Amcrest RPC','Ffmpeg','Dahua_RPC',1,1,1,1,1,25,1,1,1,1,1,1,1,1,1);
INSERT INTO `Controls` (`Name`,`Type`,`Protocol`,`CanReset`,`CanReboot`,`CanZoom`,`CanZoomCon`,`HasPresets`,`NumPresets`,`HasHomePreset`,`CanSetPresets`,`CanMove`,`CanMoveDiag`,`CanMoveCon`,`CanPan`,`CanTilt`,`CanIndicatorLight`) VALUES ('Amcrest ASH21-B RPC','Ffmpeg','Dahua_RPC',1,1,0,0,0,0,0,0,1,0,1,1,1,1);
INSERT INTO `Controls` (`Name`,`Type`,`Protocol`,`CanReset`,`CanReboot`,`CanZoom`,`CanZoomCon`,`HasPresets`,`NumPresets`,`HasHomePreset`,`CanSetPresets`,`CanMove`,`CanMoveDiag`,`CanMoveCon`,`CanPan`,`CanTilt`,`CanLight`,`CanIndicatorLight`) VALUES ('Amcrest ADC2W RPC','Ffmpeg','Dahua_RPC',1,1,0,0,0,0,0,0,0,0,0,0,0,1,1);
INSERT INTO `Controls` (`Name`,`Type`,`Protocol`,`CanReset`,`CanReboot`,`CanIndicatorLight`) VALUES ('Amcrest ASH42-B RPC','Ffmpeg','Dahua_RPC',1,1,1);
INSERT INTO `Controls` VALUES (NULL,'ONVIF','Ffmpeg','ONVIF',0,0,1,1,1,0,0,0,1,NULL,NULL,NULL,NULL,0,NULL,NULL,0,0,0,0,0,NULL,NULL,NULL,NULL,0,NULL,NULL,1,0,1,0,0,0,100,1,1,0,NULL,NULL,0,0,0,0,0,NULL,NULL,NULL,NULL,0,NULL,NULL,1,0,1,0,0,0,100,1,1,0,NULL,NULL,1,20,1,1,1,1,1,0,1,1,1,NULL,NULL,NULL,NULL,0,NULL,NULL,0,NULL,1,NULL,NULL,NULL,NULL,0,NULL,NULL,0,NULL,0,0);
--

39
db/zm_update-1.39.14.sql Normal file
View File

@@ -0,0 +1,39 @@
--
-- Add CanIndicatorLight capability column to Controls (camera indicator LED on/off).
--
SET @s = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'Controls'
AND column_name = 'CanIndicatorLight') > 0,
'SELECT ''Column CanIndicatorLight already exists''',
'ALTER TABLE `Controls` ADD COLUMN `CanIndicatorLight` tinyint(3) unsigned NOT NULL default ''0'' AFTER `CanLight`'
));
PREPARE stmt FROM @s;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
--
-- Add model-specific Controls entry for the Amcrest ASH42-B.
-- This model has no PTZ; its indicator LED is controlled via LightGlobal config
-- (configManager.setConfig/getConfig, verified live). Reboot via magicBox.reboot.
--
INSERT INTO `Controls`
(`Name`,`Type`,`Protocol`,`CanReset`,`CanReboot`,`CanIndicatorLight`)
SELECT 'Amcrest ASH42-B RPC','Ffmpeg','Dahua_RPC',1,1,1
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `Controls` WHERE `Name`='Amcrest ASH42-B RPC');
--
-- The ASH21-B and ADC2W also control their indicator LED via LightGlobal
-- (verified live on both models), so enable the capability on their entries.
--
UPDATE `Controls` SET `CanIndicatorLight`=1
WHERE `Name` IN ('Amcrest ASH21-B RPC','Amcrest ADC2W RPC');
--
-- The generic entry keeps every capability enabled for testing new cameras.
-- (CanLight column was added by 1.39.12, CanIndicatorLight above.)
--
UPDATE `Controls` SET `CanLight`=1, `CanIndicatorLight`=1
WHERE `Name`='Dahua/Amcrest RPC';

View File

@@ -67,7 +67,10 @@ sub open {
$self->{state} = 'closed';
return undef;
}
my $port = $self->{port} || 80;
# RPC2 is always on the camera's web port (default 80), not the stream port.
# parse_Path() derives {port} from the RTSP URL (typically 554), which is
# wrong for RPC. Only honour {port} if it looks like a web port.
my $port = ($self->{port} && $self->{port} != 554) ? $self->{port} : 80;
# JSON-RPC auth is in-band; do not put userinfo in the URL.
$self->{RPCBase} = 'http://'.$self->{host}.':'.$port.'/';
$self->{rpc_id} = 0;
@@ -299,15 +302,51 @@ sub coaxial_white {
sub lightOn { my $self = shift; $self->coaxial_white(1); }
sub lightOff { my $self = shift; $self->coaxial_white(2); }
# Indicator LED (camera status light) via LightGlobal config.
# Used on models like the ASH42-B where LightGlobal.Enable controls the indicator.
sub indicatorLightOn { my $self = shift; $self->set_config({ name => 'LightGlobal', table => [ { Enable => JSON::MaybeXS::true } ] }); }
sub indicatorLightOff { my $self = shift; $self->set_config({ name => 'LightGlobal', table => [ { Enable => JSON::MaybeXS::false } ] }); }
sub indicatorLightStatus {
my $self = shift;
my $r = $self->rpc_call('configManager.getConfig', { name => 'LightGlobal' });
if (!$r || !$r->{result}) {
if ($r && $self->session_expired($r->{error})) {
return { Enable => undef } if !$self->login();
$r = $self->rpc_call('configManager.getConfig', { name => 'LightGlobal' });
}
}
my $enable = ($r and $r->{result} and $r->{params} and $r->{params}{table})
? $r->{params}{table}[0]{Enable} : undef;
return { Enable => (defined $enable ? ($enable ? 'On' : 'Off') : undef) };
}
# Query the current white-light state. Returns { WhiteLight => 'On'|'Off'|undef }.
# Used by the status-aware single-button toggle in the web UI.
sub lightStatus {
my $self = shift;
my $r = $self->rpc_call('CoaxialControlIO.getStatus', { channel => 0 });
if (!$r || !$r->{result}) {
if ($r && $self->session_expired($r->{error})) {
return { WhiteLight => undef } if !$self->login();
$r = $self->rpc_call('CoaxialControlIO.getStatus', { channel => 0 });
}
}
my $status = ($r and ref($r) eq 'HASH' and $r->{params}) ? $r->{params}{status} : undef;
return { WhiteLight => ($status ? $status->{WhiteLight} : undef) };
}
# Send a keepalive ping to prevent session expiry in long-running daemons.
# Call periodically (every ~30s) from the control daemon's idle loop.
sub keepAlive {
my $self = shift;
my $r = $self->rpc_call('global.keepAlive', { timeout => 20 });
if (!$r || !$r->{result}) {
Debug('Dahua_RPC: keepAlive failed, re-logging in');
$self->login();
}
}
# ---------------------------------------------------------------------------
# Config / probe interface
# ---------------------------------------------------------------------------
@@ -357,6 +396,18 @@ sub set_config {
my ($self, $diff) = @_;
return undef unless ref($diff) eq 'HASH' and $diff->{name} and exists $diff->{table};
my $r = $self->rpc_call('configManager.setConfig', $diff);
if (!$r || !$r->{result}) {
my $msg = $r ? ($r->{error}{message} // 'result=false') : 'no response';
Error('Dahua_RPC: set_config('.$diff->{name}.') failed: '.$msg);
# Re-login on session expiry and retry once (same pattern as ptz_raw).
if ($r && $self->session_expired($r->{error})) {
Debug('Dahua_RPC: session expired during set_config, re-logging in');
return undef if !$self->login();
$r = $self->rpc_call('configManager.setConfig', $diff);
Error('Dahua_RPC: set_config('.$diff->{name}.') still failed after re-login')
if !$r || !$r->{result};
}
}
return $r ? $r->{result} : undef;
}

View File

@@ -197,7 +197,9 @@ if ($options{command}) {
vec($rin, fileno(SERVER), 1) = 1;
my $win = $rin;
my $ein = $win;
my $timeout = MAX_COMMAND_WAIT;
# Use a short timeout if the module supports keepAlive so the session
# does not expire between commands (Dahua cameras expire after ~60s idle).
my $timeout = $control->can('keepAlive') ? 30 : MAX_COMMAND_WAIT;
while (!$zm_terminate) {
my $nfound = select(my $rout = $rin, undef, undef, $timeout);
if ( $nfound > 0 ) {
@@ -246,6 +248,7 @@ if ($options{command}) {
}
} else {
Debug('Select timed out');
$control->keepAlive() if $control->can('keepAlive');
}
} # end while !$zm_terminate
Info("Control server $id/$protocol exiting");

View File

@@ -1 +1 @@
1.39.13
1.39.14

View File

@@ -99,6 +99,7 @@ class Control extends ZM_Object {
'MinWhiteSpeed' => NULL,
'MaxWhiteSpeed' => NULL,
'CanLight' => 0,
'CanIndicatorLight' => 0,
'HasPresets' => 0,
'NumPresets' => 0,
'HasHomePreset' => 0,
@@ -125,6 +126,8 @@ class Control extends ZM_Object {
$cmds['Reboot'] = 'reboot';
$cmds['LightOn'] = 'lightOn';
$cmds['LightOff'] = 'lightOff';
$cmds['IndicatorLightOn'] = 'indicatorLightOn';
$cmds['IndicatorLightOff'] = 'indicatorLightOff';
$cmds['PresetSet'] = 'presetSet';
$cmds['PresetGoto'] = 'presetGoto';

View File

@@ -205,6 +205,8 @@ $SLANG = array(
'CanWhite' => 'Can White Balance',
'CanWhiteCon' => 'Can White Bal. Continuous',
'CanLight' => 'Can Light',
'CanIndicatorLight' => 'Can Indicator Light',
'Indicator' => 'Indicator',
'CanWhiteRel' => 'Can White Bal. Relative',
'CanZoomAbs' => 'Can Zoom Absolute',
'CanZoom' => 'Can Zoom',

View File

@@ -45,7 +45,8 @@
}
/* Lit/amber when the camera reports the light is on; clicking then turns it off. */
.ptzControls .controlsPanel .lightToggleBtn.active {
.ptzControls .controlsPanel .lightToggleBtn.active,
.ptzControls .controlsPanel .indicatorLightToggleBtn.active {
background-color: #ffc107;
color: #000;
}

View File

@@ -124,6 +124,16 @@ function controlLight($monitor, $cmds) {
return ob_get_clean();
}
function controlIndicatorLight($monitor, $cmds) {
ob_start();
?>
<div class="lightControls">
<button type="button" class="ptzTextBtn indicatorLightToggleBtn" data-on-click="controlCmd" data-on-cmd="<?php echo $cmds['IndicatorLightOn'] ?>" data-off-cmd="<?php echo $cmds['IndicatorLightOff'] ?>" value="<?php echo $cmds['IndicatorLightOn'] ?>"><?php echo translate('Indicator') ?></button>
</div>
<?php
return ob_get_clean();
}
function controlPanTilt($monitor, $cmds) {
$control = $monitor->Control();
ob_start();
@@ -253,6 +263,8 @@ function ptzControls($monitor) {
echo controlWhite($monitor, $cmds);
if ( $control->CanLight() )
echo controlLight($monitor, $cmds);
if ( $control->CanIndicatorLight() )
echo controlIndicatorLight($monitor, $cmds);
if ( $control->CanMove() ) {
?>
<div class="pantiltPanel">

View File

@@ -47,6 +47,7 @@ $controls = dbFetchAll('SELECT * FROM Controls ORDER BY Name');
<th class="colCanIris" data-sortable="true" data-field="CanIris"><?php echo translate('CanIris') ?></th>
<th class="colCanWhiteBal" data-sortable="true" data-field="CanWhiteBal"><?php echo translate('CanWhiteBal') ?></th>
<th class="colCanLight" data-sortable="true" data-field="CanLight"><?php echo translate('CanLight') ?></th>
<th class="colCanIndicatorLight" data-sortable="true" data-field="CanIndicatorLight"><?php echo translate('CanIndicatorLight') ?></th>
<th class="colHasPresets" data-sortable="true" data-field="HasPresets"><?php echo translate('HasPresets') ?></th>
</tr>
</thead>
@@ -66,6 +67,7 @@ foreach( $controls as $control ) {
<td class="colCanIris"><?php echo $control['CanIris']?translate('Yes'):translate('No') ?></td>
<td class="colCanWhiteBal"><?php echo $control['CanWhite']?translate('Yes'):translate('No') ?></td>
<td class="colCanLight"><?php echo $control['CanLight']?translate('Yes'):translate('No') ?></td>
<td class="colCanIndicatorLight"><?php echo $control['CanIndicatorLight']?translate('Yes'):translate('No') ?></td>
<td class="colHasPresets"><?php echo $control['HasHomePreset']?'H':'' ?><?php echo $control['HasPresets']?$control['NumPresets']:'0' ?></td>
</tr>
<?php

View File

@@ -126,6 +126,7 @@ if ( isset($_REQUEST['Control']) ) {
'MaxGainSpeed' => '',
'CanWhite' => '',
'CanLight' => '',
'CanIndicatorLight' => '',
'CanAutoWhite' => '',
'CanWhiteAbs' => '',
'CanWhiteRel' => '',
@@ -238,6 +239,10 @@ switch ( $name ) {
<th class="text-right pr-3" scope="row"><?php echo translate('CanLight') ?></th>
<td><input type="checkbox" name="Control[CanLight]" value="1"<?php if ( !empty($Control['CanLight']) ) { ?> checked="checked"<?php } ?>/></td>
</tr>
<tr>
<th class="text-right pr-3" scope="row"><?php echo translate('CanIndicatorLight') ?></th>
<td><input type="checkbox" name="Control[CanIndicatorLight]" value="1"<?php if ( !empty($Control['CanIndicatorLight']) ) { ?> checked="checked"<?php } ?>/></td>
</tr>
<tr>
<th class="text-right pr-3" scope="row"><?php echo translate('CanReset') ?></th>
<td><input type="checkbox" name="Control[CanReset]" value="1"<?php if ( !empty($Control['CanReset']) ) { ?> checked="checked"<?php } ?>/></td>

View File

@@ -423,6 +423,26 @@ function updateLightButton(respObj) {
// (un-highlighted, sends the on command) as a plain toggle.
}
function indicatorLightStatusReq() {
if (!$j('.indicatorLightToggleBtn').length) return;
const data = {control: 'indicatorLightStatus', response: 1};
if (auth_hash) data.auth = auth_hash;
$j.getJSON(monitorUrl + '?view=request&request=control&id='+monitorId, data)
.done(updateIndicatorLightButton)
.fail(logAjaxFail);
}
function updateIndicatorLightButton(respObj) {
const btn = $j('.indicatorLightToggleBtn');
if (!btn.length) return;
const state = (respObj && respObj.status) ? respObj.status.Enable : null;
if (state == 'On') {
btn.addClass('active').val(btn.attr('data-off-cmd'));
} else if (state == 'Off') {
btn.removeClass('active').val(btn.attr('data-on-cmd'));
}
}
function controlCmd(event) {
button = event.target;
@@ -1168,6 +1188,12 @@ function initPage() {
setTimeout(lightStatusReq, 800);
});
}
if ($j('.indicatorLightToggleBtn').length) {
indicatorLightStatusReq();
$j(document).on('click', '.indicatorLightToggleBtn', function() {
setTimeout(indicatorLightStatusReq, 800);
});
}
} // initPage
function watchFullscreen() {