Files
zoneminder/db/zm_update-1.39.30.sql
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

61 lines
2.2 KiB
SQL

--
-- Add Monitors.DeviceClass and the MonitorActions table.
--
-- A speaker is a monitor: it has an address, credentials, a stream and a
-- Controls entry, and everything ZoneMinder already knows how to do with a
-- monitor applies to it. What it is not is a camera, and the console, the
-- montage and the action editor all need to tell the difference. DeviceClass
-- carries that distinction without disturbing Type, which selects the capture
-- backend: an IP speaker still captures over Ffmpeg like any other RTSP
-- device.
--
-- MonitorActions records what a monitor does when it alarms. The device
-- acted on is identified by TargetMonitorId and is frequently NOT the monitor
-- that triggered - the point of the feature is that a camera can sound a
-- speaker somewhere else.
--
SELECT 'Checking for DeviceClass in Monitors';
SET @s = (SELECT IF(
(SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE table_name = 'Monitors'
AND table_schema = DATABASE()
AND column_name = 'DeviceClass'
) > 0,
"SELECT 'Column DeviceClass already exists in Monitors'",
"ALTER TABLE `Monitors` ADD COLUMN `DeviceClass` enum('Camera','Speaker') NOT NULL default 'Camera' AFTER `Type`"
));
PREPARE stmt FROM @s;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SELECT 'Checking for the MonitorActions table';
SET @s = (SELECT IF(
(SELECT COUNT(*)
FROM INFORMATION_SCHEMA.TABLES
WHERE table_name = 'MonitorActions'
AND table_schema = DATABASE()
) > 0,
"SELECT 'Table MonitorActions already exists'",
"CREATE TABLE `MonitorActions` (
`Id` int(10) unsigned NOT NULL auto_increment,
`MonitorId` int(10) unsigned NOT NULL,
`TriggerOn` enum('EventStart','EventEnd','Alarm','Manual') NOT NULL default 'EventStart',
`ActionType` enum('LightOn','LightOff','IndicatorLightOn','IndicatorLightOff','AudioPlay','AudioStop') NOT NULL default 'AudioPlay',
`TargetMonitorId` int(10) unsigned NOT NULL,
`AudioFile` int(10) unsigned default NULL,
`Label` varchar(64) NOT NULL default '',
`Enabled` tinyint(1) unsigned NOT NULL default '1',
`Sequence` smallint(5) unsigned NOT NULL default '0',
PRIMARY KEY (`Id`),
KEY `MonitorId_TriggerOn` (`MonitorId`,`TriggerOn`),
KEY `TargetMonitorId` (`TargetMonitorId`)
)"
));
PREPARE stmt FROM @s;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;