Files
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

116 lines
3.6 KiB
PHP

<?php
namespace ZM;
require_once('database.php');
require_once('Object.php');
require_once('Control.php');
require_once('Monitor.php');
class MonitorAction extends ZM_Object {
protected static $table = 'MonitorActions';
protected $defaults = array(
'Id' => null,
'MonitorId' => 0,
'TriggerOn' => 'EventStart',
'ActionType' => 'AudioPlay',
'TargetMonitorId' => 0,
'AudioFile' => null,
'Label' => '',
'Enabled' => 1,
'Sequence' => 0,
);
public static function find($parameters = array(), $options = array()) {
return ZM_Object::_find(self::class, $parameters, $options);
}
public static function find_one($parameters = array(), $options = array()) {
return ZM_Object::_find_one(self::class, $parameters, $options);
}
// The zmcontrol command an action type maps onto. Mirrors
// Monitor::ActionCommandName in src/zm_monitor.cpp; keep the two in step.
public static function commandFor($action_type) {
$commands = array(
'LightOn' => 'lightOn',
'LightOff' => 'lightOff',
'IndicatorLightOn' => 'indicatorLightOn',
'IndicatorLightOff' => 'indicatorLightOff',
'AudioPlay' => 'audioPlay',
'AudioStop' => 'audioStop',
);
return isset($commands[$action_type]) ? $commands[$action_type] : null;
}
// Which action types a given monitor can actually perform, decided by its
// Controls row rather than assumed. A monitor with no control, or one whose
// capabilities were measured as absent, offers nothing.
public static function typesForMonitor($monitor) {
$types = array();
if (!$monitor or !$monitor->Controllable() or !$monitor->ControlId())
return $types;
$control = $monitor->Control();
if (!$control or !$control->Id())
return $types;
if ($control->CanLight()) {
$types[] = 'LightOn';
$types[] = 'LightOff';
}
if ($control->CanIndicatorLight()) {
$types[] = 'IndicatorLightOn';
$types[] = 'IndicatorLightOff';
}
if ($control->CanAudioPlay()) {
$types[] = 'AudioPlay';
$types[] = 'AudioStop';
}
return $types;
}
// Monitors that can be the target of an action, with the capabilities the
// editor needs to constrain its inputs.
public static function targetCandidates() {
$candidates = array();
foreach (Monitor::find(array('Deleted' => 0)) as $monitor) {
$types = self::typesForMonitor($monitor);
if (!count($types))
continue;
$control = $monitor->Control();
$candidates[] = array(
'Id' => $monitor->Id(),
'Name' => $monitor->Name(),
'DeviceClass' => $monitor->DeviceClass(),
'Types' => $types,
'MinAudioFile' => $control->MinAudioFile(),
'MaxAudioFile' => $control->MaxAudioFile(),
);
}
return $candidates;
}
public function Monitor() {
return Monitor::find_one(array('Id' => $this->{'MonitorId'}));
}
public function TargetMonitor() {
return Monitor::find_one(array('Id' => $this->{'TargetMonitorId'}));
}
// Build the zmcontrol option string this action sends, in the same form
// buildControlCommand produces for the control panel.
public function controlCommand() {
$command = self::commandFor($this->{'ActionType'});
if (!$command)
return null;
$options = '';
if ($this->{'ActionType'} == 'AudioPlay' and $this->{'AudioFile'} !== null)
$options .= ' --file='.validInt($this->{'AudioFile'});
return $options.' --command='.$command;
}
}
?>