From ad70d5e80a05d534f6639273ea55c6f584eec146 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Thu, 10 Sep 2026 18:45:07 -0500 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8 --- db/zm_create.sql.in | 2 +- db/zm_update-1.39.33.sql | 32 +++++++++++++++++++++++++++++ src/zm_monitor.cpp | 16 +++++++++++++++ src/zm_monitor.h | 7 ++++++- tests/zm_monitor_action.cpp | 14 +++++++++++++ version.txt | 2 +- web/lang/en_gb.php | 1 + web/skins/classic/views/monitor.php | 1 + 8 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 db/zm_update-1.39.33.sql diff --git a/db/zm_create.sql.in b/db/zm_create.sql.in index 1fffa4f15..0b67ddc4d 100644 --- a/db/zm_create.sql.in +++ b/db/zm_create.sql.in @@ -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, diff --git a/db/zm_update-1.39.33.sql b/db/zm_update-1.39.33.sql new file mode 100644 index 000000000..4888cbe42 --- /dev/null +++ b/db/zm_update-1.39.33.sql @@ -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; diff --git a/src/zm_monitor.cpp b/src/zm_monitor.cpp index 1c299ec4d..dbc29b550 100644 --- a/src/zm_monitor.cpp +++ b/src/zm_monitor.cpp @@ -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; diff --git a/src/zm_monitor.h b/src/zm_monitor.h index 78810fb7b..86019f339 100644 --- a/src/zm_monitor.h +++ b/src/zm_monitor.h @@ -178,7 +178,7 @@ class Monitor : public std::enable_shared_from_this { // 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 { std::string event_start_command; std::string event_end_command; std::vector actions; + // Whether the Alarm actions have run for the alarm currently in progress. + bool alarm_actions_fired; std::vector groups; @@ -1102,6 +1104,9 @@ class Monitor : public std::enable_shared_from_this { 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 diff --git a/tests/zm_monitor_action.cpp b/tests/zm_monitor_action.cpp index 9137832f6..05a83f42a 100644 --- a/tests/zm_monitor_action.cpp +++ b/tests/zm_monitor_action.cpp @@ -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") { diff --git a/version.txt b/version.txt index 14af19fb9..5937223ce 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.39.32 +1.39.33 diff --git a/web/lang/en_gb.php b/web/lang/en_gb.php index c4e9d5973..79b4373f9 100644 --- a/web/lang/en_gb.php +++ b/web/lang/en_gb.php @@ -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', diff --git a/web/skins/classic/views/monitor.php b/web/skins/classic/views/monitor.php index 406103636..9d1e05542 100644 --- a/web/skins/classic/views/monitor.php +++ b/web/skins/classic/views/monitor.php @@ -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(