Files
zoneminder/web/includes/actions/monitor.php
T
Isaac Connor eafdffcd95 feat: add per-monitor actions that drive a light or a speaker on alarm
A camera that detects motion should be able to sound a speaker, and the
speaker is rarely the camera: it is a separate device with its own address,
credentials, stream and Controls entry. Model it as a monitor and let a
monitor's alarm drive actions on other monitors.

Add Monitors.DeviceClass enum('Camera','Speaker'). This is what the device
is, as distinct from Type, which selects the capture backend - an IP speaker
still captures over Ffmpeg like any other RTSP device, so Type could not
carry the distinction.

Add the MonitorActions table: MonitorId is the monitor that triggers,
TargetMonitorId the device acted on, and the two are frequently different.
TriggerOn covers EventStart, EventEnd, Alarm and Manual.

Which actions a device is offered is decided by its Controls row - CanLight,
CanIndicatorLight, CanAudioPlay - so a device can only be asked to do what it
has been measured to do. The editor filters on this and the save path
re-checks it, because the request is not to be trusted.

Execution goes straight to the target's zmcontrol socket rather than forking
zmcontrol.pl per action: the daemon already accepts a line of JSON there, and
it is the same path the control panel uses. ActionCommandName maps the DB
enum onto a method name as a whitelist, so nothing out of the database
reaches the control daemon uninspected. Actions are fire-and-forget - a
speaker that is offline is logged and skipped, never allowed to hold up event
handling.

Alarm actions fire only on the genuine entry into alarm, not on the
ALERT->ALARM re-entry, which would re-sound a speaker within one incident.
EventEnd runs on the calling thread before the event is handed to the closing
thread, which does not capture `this`.

Manual actions appear as buttons on the watch page, and are the reason the
control panel is now shown for a monitor that has actions but no control of
its own. Firing one sends only the action id; the command and target are
rebuilt server side, and Control rights are required on the target device and
not merely on the monitor the action hangs off.

Also add --file to zmcontrol.pl, without which audioPlay could not be driven
from the command line. The web path was unaffected as it bypasses GetOptions.

Tests: tests/zm_monitor_action.cpp covers the command whitelist, the message
format (including that file id 0 is a real id and that a stale AudioFile is
never passed to a command that takes none), trigger names, and that every
value of the ActionType enum maps to a command.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
(cherry picked from commit b561341e988af69c3a46db854da410309f7cfa82)
2026-09-11 20:13:02 -05:00

447 lines
18 KiB
PHP

<?php
//
// ZoneMinder web action file
// Copyright (C) 2019 ZoneMinder LLC
//
// 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.
//
// Monitor edit actions, monitor id derived, require edit permissions for that monitor
require_once('includes/Monitor.php');
require_once('includes/Zone.php');
global $error_message;
if ($action == 'save') {
$mid = 0;
if (!empty($_REQUEST['mid'])) {
$mid = validInt($_REQUEST['mid']);
if (!canEdit('Monitors', $mid)) {
ZM\Warning('You do not have permission to edit this monitor');
return;
}
if (ZM_OPT_X10) {
$x10Monitor = dbFetchOne('SELECT * FROM TriggersX10 WHERE MonitorId=?', NULL, array($mid));
if (!$x10Monitor) $x10Monitor = array();
}
} else {
if (!canCreate('Monitors')) {
ZM\Warning('Monitor actions require Monitors Create Permissions');
return;
}
if ($user->unviewableMonitorIds()) {
ZM\Warning('You are restricted to certain monitors so cannot add a new one.');
return;
}
if (ZM_OPT_X10) {
$x10Monitor = array();
}
if (!empty($_REQUEST['newMonitor']['Id'])) {
// Reuse existing but deleted monitor
$mid = validCardinal($_REQUEST['newMonitor']['Id']);
}
}
# For convenience
$newMonitor = $_REQUEST['newMonitor'];
# Validate Device path to prevent command injection (CVE-worthy).
# Only Local monitors pass Device to a shell; for other Types the field
# is unused and may legitimately hold legacy values (e.g. an RTSP URL).
if (!empty($newMonitor['Device']) and isset($newMonitor['Type']) and $newMonitor['Type'] == 'Local') {
$newMonitor['Device'] = validDevicePath($newMonitor['Device']);
if ($newMonitor['Device'] === '') {
$error_message .= 'Invalid device path. Must be a valid /dev/ path (e.g. /dev/video0).</br>';
return;
}
}
if (!$newMonitor['ManufacturerId'] and ($newMonitor['Manufacturer'] != '')) {
# Need to add a new Manufacturer entry
$newManufacturer = ZM\Manufacturer::find_one(array('Name'=>$newMonitor['Manufacturer']));
if (!$newManufacturer) {
$newManufacturer = new ZM\Manufacturer();
if (!$newManufacturer->save(array('Name'=>$newMonitor['Manufacturer']))) {
$error_message .= "Error saving new Manufacturer: " . $newManufacturer->get_last_error().'</br>';
}
}
$newMonitor['ManufacturerId'] = $newManufacturer->Id();
unset($newMonitor['Manufacturer']);
}
if (!$newMonitor['ModelId'] and ($newMonitor['Model'] != '')) {
# Need to add a new Model entry
$newModel = ZM\Model::find_one(array('Name'=>$newMonitor['Model']));
if (!$newModel) {
$newModel = new ZM\Model();
if (!$newModel->save(array(
'Name'=>$newMonitor['Model'],
'ManufacturerId'=>$newMonitor['ManufacturerId']
))) {
$error_message .= "Error saving new Model: " . $newModel->get_last_error().'</br>';
}
}
$newMonitor['ModelId'] = $newModel->Id();
unset($newMonitor['Model']);
}
$monitor = new ZM\Monitor($mid);
// Define a field type for anything that's not simple text equivalent
$types = array(
'Triggers' => array(),
'Controllable' => 0,
'TrackMotion' => 0,
'ModectDuringPTZ' => 0,
'Enabled' => 0,
'DecodingEnabled' => 0,
'RTSP2WebEnabled' => 0,
'Go2RTCEnabled' => 0,
'JanusEnabled' => 0,
'JanusAudioEnabled' => 0,
'Restream' => 0,
// 'Janus_RTSP_Session_Timeout' => 0,
'Exif' => 0,
'RTSPDescribe' => 0,
'V4LMultiBuffer' => '',
'WallClockTimestamps' => '',
'RecordAudio' => 0,
'Method' => 'raw',
'GroupIds' => array(),
'LinkedMonitors' => array(),
'MQTT_Enabled' => 0,
'RTSPServer' => 0,
'SectionLengthWarn' => 0,
'SOAP_wsa_compl' => 0
);
# Checkboxes don't return an element in the POST data, so won't be present in newMonitor.
# So force a value for these fields.
# Deleted is deliberately NOT in this list: its checkbox is an "undelete"
# action, present only on a deleted monitor and only ever sending 0. Forcing
# a default of 0 when it is absent undeleted the monitor on every save.
foreach ($types as $field => $value) {
if (!isset($newMonitor[$field])) {
$newMonitor[$field] = $value;
}
} # end foreach type
if (isset($newMonitor['ServerId']) and ($newMonitor['ServerId'] == 'auto')) {
$newMonitor['ServerId'] = dbFetchOne(
'SELECT Id FROM Servers WHERE Status=\'Running\' ORDER BY FreeMem DESC, CpuLoad ASC LIMIT 1', 'Id');
ZM\Debug('Auto selecting server: Got ' . $newMonitor['ServerId']);
if ((!$newMonitor['ServerId']) and defined('ZM_SERVER_ID')) {
$newMonitor['ServerId'] = ZM_SERVER_ID;
ZM\Debug('Auto selecting server to '.ZM_SERVER_ID);
}
}
if (!empty($newMonitor['ManufacturerId']) and empty($newMonitor['Manufacturer']))
unset($newMonitor['Manufacturer']);
if (!empty($newMonitor['ModelId']) and empty($newMonitor['Model']))
unset($newMonitor['Model']);
$changes = $monitor->changes($newMonitor);
ZM\Debug('Changes: '. print_r($changes, true));
$restart = false;
if (count($changes)) {
// monitor->Id() has a value when the db record exists
if ($monitor->Id()) {
if ($monitor->Deleted() and empty($_REQUEST['mid'])) {
# We are saving a new monitor with a specified Id and the Id is used in a deleted record.
# Undelete it so that it is visible.
# Editing an existing deleted monitor (mid in the request) must NOT
# undelete it - that only happens via the explicit undelete checkbox,
# which arrives as newMonitor[Deleted]=0 and so shows up in $changes.
$monitor->Deleted(false);
}
# If we change anything that changes the shared mem size, zma can complain. So let's stop first.
if ($monitor->Type() != 'WebSite') {
$monitor->zmcControl('stop');
if ($monitor->Controllable()) {
$monitor->sendControlCommand('stop');
}
}
$oldMonitor = clone $monitor;
if ($monitor->save($changes)) {
# If the name has changed, it must also be changed in all storage areas.
if (isset($changes['Name'])) {
$saferName = basename($newMonitor['Name']);
$oldLinkPath = $oldMonitor->Storage()->Path().'/'.basename($oldMonitor->Name());
$oldLinkPathFound = false;
require_once('includes/Storage.php');
foreach (ZM\Storage::find() as $Storage) {
# Let's remove old symlinks
$storagePath = realpath($Storage->Path()) .'/';
$dirStoragePath = opendir($storagePath);
if($dirStoragePath === false) {
ZM\Warning("Unable to check and delete old symlinks to monitor with ID=".$monitor->Id()." in the '".$Storage->Name()."' storage because the path to the storage is missing or unreadable.");
} else {
while (($file = readdir($dirStoragePath)) !== false) {
$linkPath = $storagePath . $file;
if (is_link($linkPath)) {
$absolutePath = realpath($linkPath);
if ($oldLinkPath == $linkPath) $oldLinkPathFound = true;
if ($absolutePath == $Storage->Path().'/'.$mid) {
ZM\Debug("Deleting old link in storage '" . $Storage->Name() . "' " . $linkPath);
unlink($linkPath);
}
}
}
closedir($dirStoragePath);
}
# Let's create new symlinks
$dir = $Storage->Path().'/'.$mid;
if (is_dir($dir) && (string)$saferName !== (string)$mid) { # Let's check if there is a folder with events in this storage
$linkPath = $Storage->Path().'/'.$saferName;
if (!is_link($linkPath) && !@symlink($mid, $linkPath)) {
# It is necessary to check is_link() to avoid unnecessary warnings, since different repositories can have the same path and this is not prohibited by the configuration.
ZM\Warning('Unable to symlink in storage "' . $Storage->Name() . '" ' . $dir . ' to ' . $linkPath);
}
}
}
if (!$oldLinkPathFound) {
ZM\Debug("Old link at '" . $oldLinkPath . "' was not found in any of the storages");
}
}
if (isset($changes['Width']) || isset($changes['Height'])) {
$newW = $newMonitor['Width'];
$newH = $newMonitor['Height'];
$zones = dbFetchAll('SELECT * FROM Zones WHERE MonitorId=?', NULL, array($mid));
// Zone coords are stored as percentages, so they don't need rescaling
// on resolution change. Only handle rotation (swap x/y) and rescale
// threshold pixel counts.
$newA = $newW * $newH;
$oldA = $oldMonitor->Width() * $oldMonitor->Height();
if ( ($newW == $oldMonitor->Height()) and ($newH == $oldMonitor->Width()) ) {
// Rotation: swap x,y percentage coords
foreach ( $zones as $zone ) {
$newZone = $zone;
$points = coordsToPoints($zone['Coords']);
for ( $i = 0; $i < count($points); $i++ ) {
$x = $points[$i]['x'];
$points[$i]['x'] = $points[$i]['y'];
$points[$i]['y'] = $x;
}
$newZone['Coords'] = pointsToCoords($points);
$changes = getFormChanges($zone, $newZone, $types);
if ( count($changes) ) {
dbQuery('UPDATE Zones SET '.implode(', ', $changes).' WHERE MonitorId=? AND Id=?',
array($mid, $zone['Id']));
}
} # end foreach zone
} else if ($oldA > 0 && $newA != $oldA) {
// Non-rotation resize: coords stay the same (percentages),
// but rescale threshold pixel counts by area ratio
foreach ( $zones as $zone ) {
$newZone = $zone;
$newZone['MinAlarmPixels'] = intval(round(($zone['MinAlarmPixels']*$newA)/$oldA));
$newZone['MaxAlarmPixels'] = intval(round(($zone['MaxAlarmPixels']*$newA)/$oldA));
$newZone['MinFilterPixels'] = intval(round(($zone['MinFilterPixels']*$newA)/$oldA));
$newZone['MaxFilterPixels'] = intval(round(($zone['MaxFilterPixels']*$newA)/$oldA));
$newZone['MinBlobPixels'] = intval(round(($zone['MinBlobPixels']*$newA)/$oldA));
$newZone['MaxBlobPixels'] = intval(round(($zone['MaxBlobPixels']*$newA)/$oldA));
$changes = getFormChanges($zone, $newZone, $types);
if ( count($changes) ) {
dbQuery('UPDATE Zones SET '.implode(', ', $changes).' WHERE MonitorId=? AND Id=?',
array($mid, $zone['Id']));
}
} // end foreach zone
} // end if rotation or just size change
} // end if changes in width or height
ZM\AuditAction('update', 'monitor', $mid, 'Changed: '.implode(', ', array_keys($changes)));
} else {
$error_message .= $monitor->get_last_error();
} // end if successful save
$restart = true;
} else { // new monitor
// Can only create new monitors if we are not restricted to specific monitors
# FIXME This is actually a race condition. Should lock the table.
$maxSeq = dbFetchOne('SELECT MAX(Sequence) AS MaxSequence FROM Monitors', 'MaxSequence');
$changes['Sequence'] = $maxSeq+1;
if ( $mid ) $changes['Id'] = $mid; # mid specified in request, doesn't exist in db, will re-use slot
if ( $monitor->insert($changes) ) {
$mid = $monitor->Id();
// Zone coords are now stored as percentages (0-100)
$zoneWidth = $newMonitor['Width'];
$zoneHeight = $newMonitor['Height'];
if (isset($newMonitor['Orientation']) &&
($newMonitor['Orientation'] == 'ROTATE_90' || $newMonitor['Orientation'] == 'ROTATE_270')) {
$zoneWidth = $newMonitor['Height'];
$zoneHeight = $newMonitor['Width'];
}
$zoneArea = $zoneWidth * $zoneHeight;
$zone = new ZM\Zone();
if (!$zone->save(['MonitorId'=>$monitor->Id(), 'Name'=>'All',
'Units'=>'Percent',
'Coords'=>'0.00,0.00 100.00,0.00 100.00,100.00 0.00,100.00',
'Area'=>10000,
'MinAlarmPixels'=>intval(($zoneArea*.05)/100),
'MaxAlarmPixels'=>intval(($zoneArea*75)/100),
'MinFilterPixels'=>intval(($zoneArea*.05)/100),
'MaxFilterPixels'=>intval(($zoneArea*75)/100),
'MinBlobPixels'=>intval(($zoneArea*.05)/100)
])) {
$error_message .= $zone->get_last_error();
ZM\Error('Error adding zone:' . $error_message);
}
ZM\AuditAction('create', 'monitor', $mid, 'Name: '.($newMonitor['Name'] ?? ''));
} else {
$dbError = $monitor->get_last_error();
$error_message .= 'Error saving new Monitor: '.($dbError ? $dbError : 'unknown database error').'<br/>';
ZM\Error('Error saving new Monitor: '.$dbError);
return;
}
}
$Storage = $monitor->Storage();
$mid_dir = $Storage->Path().'/'.$mid;
if (!file_exists($mid_dir)) {
if (!@mkdir($mid_dir, 0755)) {
ZM\Error('Unable to mkdir '.$Storage->Path().'/'.$mid);
}
}
$saferName = basename($newMonitor['Name']);
$link_path = $Storage->Path().'/'.$saferName;
if (((string)$saferName !== (string)$mid) and !@symlink($mid, $link_path)) {
if (!(file_exists($link_path) and is_link($link_path))) {
ZM\Warning('Unable to symlink ' . $Storage->Path().'/'.$mid . ' to ' . $link_path);
}
}
if ( isset($changes['GroupIds']) ) {
dbQuery('DELETE FROM Groups_Monitors WHERE MonitorId=?', array($mid));
foreach ( $changes['GroupIds'] as $group_id ) {
dbQuery('INSERT INTO Groups_Monitors (GroupId, MonitorId) VALUES (?,?)', array($group_id, $mid));
}
} // end if there has been a change of groups
$restart = true;
} else {
ZM\Debug('No action due to no changes to Monitor');
} # end if count(changes)
if ( !$mid ) {
ZM\Error("We should have a mid by now. Something went wrong.");
return;
}
if ( ZM_OPT_X10 ) {
$x10Changes = getFormChanges($x10Monitor, $_REQUEST['newX10Monitor']);
if ( count($x10Changes) ) {
if ( $x10Monitor && isset($_REQUEST['newX10Monitor']) ) {
dbQuery('UPDATE TriggersX10 SET '.implode(', ', $x10Changes).' WHERE MonitorId=?', array($mid));
} else if ( !$user->unviewableMonitorIds() ) {
if ( !$x10Monitor ) {
dbQuery('INSERT INTO TriggersX10 SET MonitorId = ?, '.implode(', ', $x10Changes), array($mid));
} else {
dbQuery('DELETE FROM TriggersX10 WHERE MonitorId = ?', array($mid));
}
}
$restart = true;
} # end if has x10Changes
} # end if ZM_OPT_X10
if ( isset($_REQUEST['newAction']) and is_array($_REQUEST['newAction']) ) {
require_once('includes/MonitorAction.php');
$submitted_ids = array();
foreach ( $_REQUEST['newAction'] as $posted ) {
# A row with no target is how the editor expresses "unused": the spare
# blank row on every page, or an existing action the operator cleared.
$target = empty($posted['TargetMonitorId']) ? 0 : validInt($posted['TargetMonitorId']);
$action_id = empty($posted['Id']) ? 0 : validInt($posted['Id']);
if ( !$target ) {
if ( $action_id )
dbQuery('DELETE FROM MonitorActions WHERE Id=? AND MonitorId=?', array($action_id, $mid));
continue;
}
# Never store an action the target cannot perform. The editor filters the
# dropdown, but the request is not to be trusted. A disabled select is
# not submitted at all, so the key may simply be absent.
$action_type = isset($posted['ActionType']) ? $posted['ActionType'] : '';
$trigger_on = isset($posted['TriggerOn']) ? $posted['TriggerOn'] : 'EventStart';
$target_monitor = ZM\Monitor::find_one(array('Id'=>$target));
$allowed = ZM\MonitorAction::typesForMonitor($target_monitor);
if ( !in_array($action_type, $allowed) ) {
ZM\Warning('Ignoring action: monitor '.$target.' cannot '.$action_type);
continue;
}
$values = array(
'MonitorId' => $mid,
'TargetMonitorId' => $target,
'TriggerOn' => $trigger_on,
'ActionType' => $action_type,
'AudioFile' => (!isset($posted['AudioFile']) or $posted['AudioFile'] === '')
? null : validInt($posted['AudioFile']),
'Enabled' => empty($posted['Enabled']) ? 0 : 1,
);
$action = $action_id ?
ZM\MonitorAction::find_one(array('Id'=>$action_id, 'MonitorId'=>$mid)) : new ZM\MonitorAction();
if ( !$action ) $action = new ZM\MonitorAction();
if ( $action->Id() ) {
$action->save($values);
$submitted_ids[] = $action->Id();
} else if ( $action->insert($values) ) {
$submitted_ids[] = $action->Id();
} else {
$error_message .= 'Error saving monitor action: '.$action->get_last_error().'<br/>';
}
} # end foreach posted action
# zmc re-reads the action list on restart, so a change here needs one.
$restart = true;
} # end if newAction
if ( $restart ) {
if ( $monitor->Capturing() != 'None' and $monitor->Type() != 'WebSite' and !$monitor->Deleted()) {
$monitor->zmcControl('start');
if ( $monitor->Controllable() ) {
$monitor->sendControlCommand('start');
}
}
// really should thump zmwatch and maybe zmtrigger too.
//daemonControl( 'restart', 'zmwatch.pl' );
} // end if restart
if (!$error_message)
$redirect = '?view=console';
} else {
ZM\Warning("Unknown action $action in Monitor");
} // end if action == Delete
?>