Files
zoneminder/tests/zm_monitor_action.cpp
Isaac ConnorandClaude Opus 5 ad70d5e80a 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
2026-09-11 20:13:03 -05:00

157 lines
6.8 KiB
C++

/*
* This file is part of the ZoneMinder Project. See AUTHORS file for Copyright information
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "zm_catch2.h"
#include "zm_monitor.h"
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
namespace {
std::string ReadFile(const std::filesystem::path &path) {
std::ifstream input(path);
REQUIRE(input.is_open());
std::ostringstream buffer;
buffer << input.rdbuf();
return buffer.str();
}
Monitor::EventAction MakeAction(const std::string &type, int audio_file = -1) {
Monitor::EventAction action;
action.trigger = Monitor::EventAction::EVENT_START;
action.action_type = type;
action.target_monitor_id = 2;
action.audio_file = audio_file;
return action;
}
} // namespace
TEST_CASE("Monitor action type maps onto a control command") {
SECTION("every stored action type has a command") {
REQUIRE(std::string(Monitor::ActionCommandName("LightOn")) == "lightOn");
REQUIRE(std::string(Monitor::ActionCommandName("LightOff")) == "lightOff");
REQUIRE(std::string(Monitor::ActionCommandName("IndicatorLightOn")) == "indicatorLightOn");
REQUIRE(std::string(Monitor::ActionCommandName("IndicatorLightOff")) == "indicatorLightOff");
REQUIRE(std::string(Monitor::ActionCommandName("AudioPlay")) == "audioPlay");
REQUIRE(std::string(Monitor::ActionCommandName("AudioStop")) == "audioStop");
}
SECTION("anything not on the whitelist is refused") {
// The mapping is a whitelist precisely so a value that reached the column
// by some other route cannot become a method call.
REQUIRE(Monitor::ActionCommandName("") == nullptr);
REQUIRE(Monitor::ActionCommandName("lightOn") == nullptr);
REQUIRE(Monitor::ActionCommandName("reboot") == nullptr);
REQUIRE(Monitor::ActionCommandName("AudioPlay; rm -rf /") == nullptr);
}
}
TEST_CASE("Monitor action message is the zmcontrol wire format") {
SECTION("a sound carries its file id") {
REQUIRE(Monitor::ActionMessage(MakeAction("AudioPlay", 12)) ==
"{\"command\":\"audioPlay\",\"file\":12}");
}
SECTION("file id 0 is a legitimate id, not an absent one") {
REQUIRE(Monitor::ActionMessage(MakeAction("AudioPlay", 0)) ==
"{\"command\":\"audioPlay\",\"file\":0}");
}
SECTION("a sound with no file id configured omits the argument") {
REQUIRE(Monitor::ActionMessage(MakeAction("AudioPlay")) ==
"{\"command\":\"audioPlay\"}");
}
SECTION("actions that take no file never carry one") {
// A row may keep a stale AudioFile after its type is changed; the message
// must not pass it to a command that does not understand it.
REQUIRE(Monitor::ActionMessage(MakeAction("LightOn", 12)) ==
"{\"command\":\"lightOn\"}");
REQUIRE(Monitor::ActionMessage(MakeAction("AudioStop", 12)) ==
"{\"command\":\"audioStop\"}");
}
SECTION("an unknown type yields no message at all") {
REQUIRE(Monitor::ActionMessage(MakeAction("Nonsense", 1)).empty());
}
}
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") {
const auto repo_root = std::filesystem::path(ZM_SOURCE_DIR);
SECTION("fresh schema carries DeviceClass and MonitorActions") {
const auto schema = ReadFile(repo_root / "db" / "zm_create.sql.in");
REQUIRE(schema.find("`DeviceClass` enum('Camera','Speaker') NOT NULL default 'Camera'") != std::string::npos);
REQUIRE(schema.find("CREATE TABLE `MonitorActions`") != std::string::npos);
REQUIRE(schema.find("`TargetMonitorId` int(10) unsigned NOT NULL") != std::string::npos);
}
SECTION("upgrade migration adds both") {
const auto migration = ReadFile(repo_root / "db" / "zm_update-1.39.30.sql");
REQUIRE(migration.find("ADD COLUMN `DeviceClass` enum('Camera','Speaker')") != std::string::npos);
REQUIRE(migration.find("CREATE TABLE `MonitorActions`") != std::string::npos);
// Re-running an upgrade must not duplicate either object.
REQUIRE(migration.find("INFORMATION_SCHEMA.COLUMNS") != std::string::npos);
REQUIRE(migration.find("INFORMATION_SCHEMA.TABLES") != std::string::npos);
}
SECTION("the action enum and the C++ whitelist agree") {
// The DB enum is the only source of ActionType values, so every one of them
// must map to a command or the row would silently never fire.
const auto schema = ReadFile(repo_root / "db" / "zm_create.sql.in");
const auto enum_start = schema.find("`ActionType` enum(");
REQUIRE(enum_start != std::string::npos);
const auto enum_end = schema.find(')', enum_start);
const auto enum_body = schema.substr(enum_start, enum_end - enum_start);
for (const char *type : {"LightOn", "LightOff", "IndicatorLightOn",
"IndicatorLightOff", "AudioPlay", "AudioStop"}) {
REQUIRE(enum_body.find(std::string("'") + type + "'") != std::string::npos);
REQUIRE(Monitor::ActionCommandName(type) != nullptr);
}
}
}