mirror of
https://github.com/ZoneMinder/zoneminder.git
synced 2026-09-12 21:27:39 -04:00
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
157 lines
6.8 KiB
C++
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);
|
|
}
|
|
}
|
|
}
|