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:
Isaac ConnorandClaude Opus 5 committed 2026-09-11 20:13:03 -05:00
1 parent 3b6ad200b4
commit ad70d5e80a
8 files changed
+72 -3

No files matched your search

+1 -1
View File
@@ -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,
+32
View File
@@ -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;
+16
View File
@@ -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
View File
@@ -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
+14
View File
@@ -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
View File
@@ -1 +1 @@
1.39.32
1.39.33
+1
View File
@@ -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',
+1
View File
@@ -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(