mirror of
https://github.com/ZoneMinder/zoneminder.git
synced 2026-10-02 15:35:09 -04:00
feat: add an AlarmEnd trigger to monitor actions
Alarm turns a light on when motion starts; nothing turned it back off. The
existing EventEnd is not the answer: the alarm ends when the monitor leaves the
alert state, while the event carries on recording for the rest of its section,
which on a continuous-recording monitor is up to ten minutes later.
Analyse() leaves the alarm condition by three paths and all three fire it: the
normal ALERT to IDLE, a signal change, and the trigger being turned off. The
abnormal two matter most - a light switched on by an alarm must not stay on
because the camera lost signal.
That needs the firing to be paired, so alarm_actions_fired is set when Alarm
fires and cleared by EndAlarmActions. It guards both directions:
- the three call sites can run back to back, so without it one alarm could
fire AlarmEnd several times
- signal loss and trigger off also run on monitors that never alarmed, and an
unpaired AlarmEnd would switch a light off for an alarm that never happened
- the ALERT to ALARM re-trigger inside one incident deliberately does not
re-fire Alarm, so a single AlarmEnd still has to balance it
The migration MODIFYs the enum and appends the value, so stored rows keep their
meaning; appending to an enum does not renumber what is already there.
Tested: 8 assertions over the pairing rule, run standalone because the real
class needs a database and a camera - the ordinary pair, repeated calls firing
once, an unpaired call firing nothing, two alarms pairing independently, and
the re-trigger case. The action test now also requires every trigger name to
round trip through the TriggerOn enum, so an action saved by the editor cannot
load with a trigger the C++ does not recognise and fire at the wrong moment.
Build clean at 1.39.33, Perl 15 files / 249 assertions, ESLint 0 problems,
tests/js 154 assertions, php -l clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
This commit is contained in:
1 parent
3b6ad200b4
commit
ad70d5e80a
8 files changed
+72
-3
No files matched your search
+1
-1
@@ -802,7 +802,7 @@ DROP TABLE IF EXISTS `Monitor_Status`;
|
||||
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',
|
||||
`TriggerOn` enum('EventStart','EventEnd','Alarm','AlarmEnd','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,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
--
|
||||
-- Add the AlarmEnd trigger to MonitorActions.
|
||||
--
|
||||
-- Alarm fires when motion starts; AlarmEnd fires when the alarm condition is
|
||||
-- over, which is what a light needs in order to be turned back off.
|
||||
--
|
||||
-- This is deliberately not the same as EventEnd. The alarm ends when the
|
||||
-- monitor leaves the alert state, whereas the event can carry on recording for
|
||||
-- the rest of its section - on a continuous-recording monitor that can be ten
|
||||
-- minutes later.
|
||||
--
|
||||
-- MODIFY rather than a new column, so the existing rows and their values are
|
||||
-- untouched; adding a value to the end of an enum does not renumber the ones
|
||||
-- already stored.
|
||||
--
|
||||
|
||||
SELECT 'Checking for the AlarmEnd trigger in MonitorActions';
|
||||
SET @s = (SELECT IF(
|
||||
(SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE table_name = 'MonitorActions'
|
||||
AND table_schema = DATABASE()
|
||||
AND column_name = 'TriggerOn'
|
||||
AND COLUMN_TYPE LIKE '%AlarmEnd%'
|
||||
) > 0,
|
||||
"SELECT 'MonitorActions already has the AlarmEnd trigger'",
|
||||
"ALTER TABLE `MonitorActions` MODIFY `TriggerOn` enum('EventStart','EventEnd','Alarm','AlarmEnd','Manual') NOT NULL default 'EventStart'"
|
||||
));
|
||||
|
||||
PREPARE stmt FROM @s;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@@ -235,6 +235,7 @@ Monitor::Monitor() :
|
||||
audio_detection(false),
|
||||
audio_threshold(0),
|
||||
audio_alarm_score(0),
|
||||
alarm_actions_fired(false),
|
||||
wallclock_timestamps(false),
|
||||
//event_prefix
|
||||
//label_format
|
||||
@@ -2260,6 +2261,7 @@ bool Monitor::Analyse() {
|
||||
} // end if doing analysing
|
||||
}
|
||||
shared_data->state = state = IDLE;
|
||||
EndAlarmActions();
|
||||
} // end if signal change
|
||||
|
||||
if (signal) {
|
||||
@@ -2471,6 +2473,7 @@ bool Monitor::Analyse() {
|
||||
// Only the genuine entry into alarm fires actions. The
|
||||
// ALERT->ALARM path below is a re-trigger within one alarm and
|
||||
// would sound a speaker repeatedly through a single incident.
|
||||
alarm_actions_fired = true;
|
||||
RunActions(EventAction::ALARM);
|
||||
} else if (state != PREALARM) {
|
||||
Info("%s: %03d - Gone into prealarm state", name.c_str(), analysis_image_count);
|
||||
@@ -2508,6 +2511,7 @@ bool Monitor::Analyse() {
|
||||
if ((analysis_image_count - last_alarm_count) > post_event_count) {
|
||||
shared_data->state = state = IDLE;
|
||||
Info("%s: %03d - Left alert state", name.c_str(), analysis_image_count);
|
||||
EndAlarmActions();
|
||||
}
|
||||
} else if (state == PREALARM) {
|
||||
// Back to IDLE
|
||||
@@ -2685,6 +2689,7 @@ bool Monitor::Analyse() {
|
||||
closeEvent();
|
||||
}
|
||||
shared_data->state = state = IDLE;
|
||||
EndAlarmActions();
|
||||
} // end if ( trigger_data->trigger_state != TRIGGER_OFF )
|
||||
|
||||
if (packet->codec_type == AVMEDIA_TYPE_VIDEO) {
|
||||
@@ -2863,6 +2868,7 @@ const char *Monitor::ActionTriggerName(EventAction::TriggerOn trigger) {
|
||||
case EventAction::EVENT_START: return "EventStart";
|
||||
case EventAction::EVENT_END: return "EventEnd";
|
||||
case EventAction::ALARM: return "Alarm";
|
||||
case EventAction::ALARM_END: return "AlarmEnd";
|
||||
case EventAction::MANUAL: return "Manual";
|
||||
}
|
||||
return "";
|
||||
@@ -2902,6 +2908,7 @@ void Monitor::LoadActions() {
|
||||
if (trigger == "EventStart") action.trigger = EventAction::EVENT_START;
|
||||
else if (trigger == "EventEnd") action.trigger = EventAction::EVENT_END;
|
||||
else if (trigger == "Alarm") action.trigger = EventAction::ALARM;
|
||||
else if (trigger == "AlarmEnd") action.trigger = EventAction::ALARM_END;
|
||||
else if (trigger == "Manual") action.trigger = EventAction::MANUAL;
|
||||
else {
|
||||
Warning("Monitor %u: ignoring action with unknown trigger '%s'", id, trigger.c_str());
|
||||
@@ -2932,6 +2939,15 @@ void Monitor::LoadActions() {
|
||||
// Actions are fire-and-forget: a speaker that is offline must never hold up
|
||||
// event handling, so a failed connect is logged and skipped rather than
|
||||
// retried. Manual actions are driven from the web ui and are never run here.
|
||||
void Monitor::EndAlarmActions() {
|
||||
// Every path out of the alarm condition calls this, including the abnormal
|
||||
// ones. A light switched on by an alarm must not stay on because the camera
|
||||
// lost signal or the trigger was turned off.
|
||||
if (!alarm_actions_fired) return;
|
||||
alarm_actions_fired = false;
|
||||
RunActions(EventAction::ALARM_END);
|
||||
}
|
||||
|
||||
void Monitor::RunActions(EventAction::TriggerOn trigger) {
|
||||
for (const EventAction &action : actions) {
|
||||
if (action.trigger != trigger) continue;
|
||||
|
||||
+6
-1
@@ -178,7 +178,7 @@ class Monitor : public std::enable_shared_from_this<Monitor> {
|
||||
// identified by target_monitor_id and is frequently NOT this monitor: the
|
||||
// point of the feature is that a camera can sound a speaker elsewhere.
|
||||
struct EventAction {
|
||||
enum TriggerOn { EVENT_START, EVENT_END, ALARM, MANUAL };
|
||||
enum TriggerOn { EVENT_START, EVENT_END, ALARM, ALARM_END, MANUAL };
|
||||
TriggerOn trigger;
|
||||
std::string action_type; // zmcontrol command, e.g. audioPlay
|
||||
unsigned int target_monitor_id;
|
||||
@@ -763,6 +763,8 @@ class Monitor : public std::enable_shared_from_this<Monitor> {
|
||||
std::string event_start_command;
|
||||
std::string event_end_command;
|
||||
std::vector<EventAction> actions;
|
||||
// Whether the Alarm actions have run for the alarm currently in progress.
|
||||
bool alarm_actions_fired;
|
||||
|
||||
std::vector<Group *> groups;
|
||||
|
||||
@@ -1102,6 +1104,9 @@ class Monitor : public std::enable_shared_from_this<Monitor> {
|
||||
void ReloadLinkedMonitors();
|
||||
|
||||
void LoadActions();
|
||||
// Fires the AlarmEnd actions once per alarm, and only when the Alarm actions
|
||||
// actually ran, so the two stay paired.
|
||||
void EndAlarmActions();
|
||||
void RunActions(EventAction::TriggerOn trigger);
|
||||
// Pure helpers, separated from RunActions so they can be tested without a
|
||||
// database, a socket or a device. ActionCommandName maps the DB enum onto a
|
||||
|
||||
@@ -100,7 +100,21 @@ TEST_CASE("Monitor action trigger names round trip") {
|
||||
REQUIRE(std::string(Monitor::ActionTriggerName(Monitor::EventAction::EVENT_START)) == "EventStart");
|
||||
REQUIRE(std::string(Monitor::ActionTriggerName(Monitor::EventAction::EVENT_END)) == "EventEnd");
|
||||
REQUIRE(std::string(Monitor::ActionTriggerName(Monitor::EventAction::ALARM)) == "Alarm");
|
||||
REQUIRE(std::string(Monitor::ActionTriggerName(Monitor::EventAction::ALARM_END)) == "AlarmEnd");
|
||||
REQUIRE(std::string(Monitor::ActionTriggerName(Monitor::EventAction::MANUAL)) == "Manual");
|
||||
|
||||
// Every name must round trip through the column, or an action saved by the
|
||||
// editor would load with the wrong trigger and fire at the wrong moment.
|
||||
const auto schema = ReadFile(std::filesystem::path(ZM_SOURCE_DIR) / "db" / "zm_create.sql.in");
|
||||
const auto enum_start = schema.find("`TriggerOn` enum(");
|
||||
REQUIRE(enum_start != std::string::npos);
|
||||
const auto enum_body = schema.substr(enum_start, schema.find(')', enum_start) - enum_start);
|
||||
for (const auto trigger : {Monitor::EventAction::EVENT_START, Monitor::EventAction::EVENT_END,
|
||||
Monitor::EventAction::ALARM, Monitor::EventAction::ALARM_END,
|
||||
Monitor::EventAction::MANUAL}) {
|
||||
REQUIRE(enum_body.find(std::string("'") + Monitor::ActionTriggerName(trigger) + "'")
|
||||
!= std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Monitor action schema") {
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.39.32
|
||||
1.39.33
|
||||
@@ -238,6 +238,7 @@ $SLANG = array(
|
||||
'ActionTriggerEventStart' => 'Event starts',
|
||||
'ActionTriggerEventEnd' => 'Event ends',
|
||||
'ActionTriggerAlarm' => 'Alarm begins',
|
||||
'ActionTriggerAlarmEnd' => 'Alarm ends',
|
||||
'ActionTriggerManual' => 'Manually triggered',
|
||||
'ActionLightOn' => 'Turn light on',
|
||||
'ActionLightOff' => 'Turn light off',
|
||||
|
||||
@@ -1634,6 +1634,7 @@ echo htmlSelect('newMonitor[ReturnLocation]', $return_options, $monitor->ReturnL
|
||||
'EventStart' => translate('ActionTriggerEventStart'),
|
||||
'EventEnd' => translate('ActionTriggerEventEnd'),
|
||||
'Alarm' => translate('ActionTriggerAlarm'),
|
||||
'AlarmEnd' => translate('ActionTriggerAlarmEnd'),
|
||||
'Manual' => translate('ActionTriggerManual'),
|
||||
);
|
||||
$all_type_options = array(
|
||||
|
||||
Reference in new issue
Block a user