Compare commits

..

5 Commits

Author SHA1 Message Date
Zii
b09ff1a19a v3.2
- added fuse3 to setup.sh since imagemagic depends on it
- fixed the double program name bug in the detection log
- releasing to main
2023-09-14 14:53:18 -04:00
Zii
8f80ce34f1 v3.2.t3
-the live stream is now a part of the camera's main page.
-fixed symm links for the main page not being created on install.
-removed deprecated ffmpeg timeout option.
-copied hls.js as a local file so live streams will still work
 without being connected to the internet.
2023-08-26 09:52:51 -04:00
Zii
3791b29cf7 v3.2.t2
-fixed the -i option that was using /etc instaead of /etc/mow
-the externalized image comparison executable is now configurable
 in the config file.
-moved the main index.html and theme.css files to the buffer dir,
 moving even more io to the buffer dir.
2023-08-06 10:17:10 -04:00
Zii
a8bd0ab7bf fixed compile error for older QT versions 2023-07-31 16:44:25 -04:00
Zii
367ecc839b v3.2.t1
-the -d option will no longer directly run camera instances. it
 will instead utilize systemd to install camera services.

-added -i, -l and -r options to manage camera services.

-the app will now default work and output directories in the
 /var/opt directory instead of /var/www/html and /tmp, making
 it a better neighbour in the fact that it won't overwrite
 existing websites or tmp files.

-install.sh will now create an unprivileged mow user that will
 be used to run camera services. all in effort to make the app
 a good neighour and follow good security practice.
2023-07-31 11:16:07 -04:00
11 changed files with 569 additions and 277 deletions

View File

@ -34,16 +34,17 @@ recording_stream = rtsp://1.2.3.4:554/h264
# this is the url to the main stream of the IP camera that will be used # this is the url to the main stream of the IP camera that will be used
# to record footage. # to record footage.
# #
web_root = /var/www/html web_root = /var/opt/mow/web
# this is the output directory that will be used to store recorded footage # this is the output directory that will be used to store recorded footage
# from the cameras as well as the web interface for the application. # from the cameras as well as the web interface for the application.
# warning: this will overwrite any existing index.html files so be sure # warning: this will overwrite any existing index.html files so be sure
# to choose a directory that doesn't have an existing website. # to choose a directory that doesn't have an existing website.
# #
buffer_path = /tmp buffer_path = /var/opt/mow/buf
# this is the work directory the app will use to store live footage and # this is the work directory the app will use to store live footage and
# image frames. it's recommended to use a ram disk for this since there # image frames. it's recommended to use a ram disk for this since there
# will be large amounts of io occuring here. # will be large amounts of io occuring here. 1g of space per camera is
# a good rule of thumb.
# #
cam_name = cam-1 cam_name = cam-1
# this is the optional camera name parameter to identify the camera. this # this is the optional camera name parameter to identify the camera. this
@ -54,11 +55,29 @@ max_event_secs = 30
# this is the maximum amount of secs of video footage that can be # this is the maximum amount of secs of video footage that can be
# recorded in a motion event. # recorded in a motion event.
# #
img_comp_cmd = magick compare -metric FUZZ &prev& &next& /dev/null
# this is the command line template this application will use when calling
# the external image comparison application. the external application is
# expected to compare snapshots from the video stream to determine how
# different the images are from each other. it needs to output a numeric
# score with the higher values meaning very different while low values
# mean similar images. the snapshots pulled from the stream will be bitmap
# formatted so the app will be required to support this format. also avoid
# outputting any special chars, only numeric chars with a single '.' if
# outputting a decimal value. magick is the default if not defined in the
# config file. the special string &prev& will be substituted with the path
# to the "previous" bitmap image, behind in time stamp to the image path
# subtituted in &next&.
#
img_comp_out = stderr
# this is the standard output stream the app defined in img_comp_cmd will
# use to output the comparison score. this can only be stderr or stdout,
# any other stream name is considered invalid.
#
img_thresh = 8000 img_thresh = 8000
# this application uses 'magick compare' to score the differences between # this parameter defines the score threshold from img_comp_cmd that will
# two, one second gapped snapshots of the camera stream. any image pairs # be considered motion. any motion events will queue up max_event_secs
# that score greater than this value is considered motion and queues up # worth of hls clips to be written out to web_root.
# max_event_secs worth of hls clips to be written out as a motion event.
# #
max_events = 100 max_events = 100
# this indicates the maximum amount of motion event video clips to keep # this indicates the maximum amount of motion event video clips to keep

2
bin/hls.js Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,11 +1,65 @@
#!/bin/sh #!/bin/sh
if [ -f "/opt/mow/uninst" ]; then
mow -u -f
fi
if [ ! -d "/opt/mow" ]; then if [ ! -d "/opt/mow" ]; then
mkdir /opt/mow mkdir /opt/mow
fi fi
cp ./.build-mow/mow /opt/mow/bin
if [ ! -d "/var/opt/mow" ]; then
mkdir /var/opt/mow
fi
if [ ! -d "/etc/mow" ]; then
mkdir /etc/mow
fi
if [ ! -d "/var/opt/mow/buf" ]; then
mkdir /var/opt/mow/buf
fi
if [ ! -d "/var/opt/mow/web" ]; then
mkdir /var/opt/mow/web
fi
if [ -e "/var/opt/mow/web/index.html" ]; then
rm -v /var/opt/mow/web/index.html
fi
if [ -e "/var/opt/mow/web/theme.css" ]; then
rm -v /var/opt/mow/web/theme.css
fi
touch /var/opt/mow/buf/index.html
touch /var/opt/mow/buf/theme.css
ln -sv /var/opt/mow/buf/index.html /var/opt/mow/web/index.html
ln -sv /var/opt/mow/buf/theme.css /var/opt/mow/web/theme.css
cp -v ./.build-mow/mow /opt/mow/bin
cp -v ./bin/hls.js /var/opt/mow/web/hls.js
echo "writing /opt/mow/run"
printf "#!/bin/sh\n" > /opt/mow/run printf "#!/bin/sh\n" > /opt/mow/run
printf "/opt/mow/bin \$1 \$2 \$3\n" >> /opt/mow/run printf "/opt/mow/bin \$1 \$2 \$3\n" >> /opt/mow/run
chmod +x /opt/mow/run
chmod +x /opt/mow/bin echo "writing /opt/mow/uninst"
rm /usr/bin/mow printf "#!/bin/sh\n" > /opt/mow/uninst
ln -s /opt/mow/run /usr/bin/mow printf "rm -v /opt/mow/bin\n" >> /opt/mow/uninst
printf "rm -v /opt/mow/run\n" >> /opt/mow/uninst
printf "rm -v /opt/mow/uninst\n" >> /opt/mow/uninst
printf "rm -v /usr/bin/mow\n" >> /opt/mow/uninst
printf "rm -rv /opt/mow\n" >> /opt/mow/uninst
printf "rm -r /var/opt/mow/buf\n" >> /opt/mow/uninst
printf "deluser mow\n" >> /opt/mow/uninst
useradd -r mow
chown -R mow:mow /var/opt/mow
chmod -v +x /opt/mow/run
chmod -v +x /opt/mow/bin
chmod -v +x /opt/mow/uninst
ln -sv /opt/mow/run /usr/bin/mow

View File

@ -2,6 +2,6 @@
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
apt update -y apt update -y
apt install -y pkg-config cmake make g++ apt install -y pkg-config cmake make g++
apt install -y ffmpeg libavcodec-dev libavformat-dev libavutil-dev libswscale-dev x264 libx264-dev libilmbase-dev qt6-base-dev qtchooser qmake6 qt6-base-dev-tools libxkbcommon-dev libfuse-dev apt install -y ffmpeg libavcodec-dev libavformat-dev libavutil-dev libswscale-dev x264 libx264-dev libilmbase-dev qt6-base-dev qtchooser qmake6 qt6-base-dev-tools libxkbcommon-dev libfuse-dev fuse3
cp ./bin/magick /usr/bin/magick cp ./bin/magick /usr/bin/magick
chmod +x /usr/bin/magick chmod +x /usr/bin/magick

View File

@ -12,31 +12,49 @@
#include "camera.h" #include "camera.h"
Camera::Camera(QObject *parent) : QObject(parent) Camera::Camera(QObject *parent) : QObject(parent) {}
{
shared.recordUrl.clear();
shared.postCmd.clear();
shared.camName.clear();
shared.retCode = 0; void Camera::cleanup()
shared.imgThresh = 8000; {
shared.maxEvents = 100; QProcess::execute("rm", {shared.outDir + "/live"});
shared.maxLogSize = 100000; QProcess::execute("rm", {shared.outDir + "/logs"});
shared.skipCmd = false; QProcess::execute("rm", {shared.outDir + "/img"});
shared.postSecs = 60; QProcess::execute("rm", {shared.outDir + "/index.html"});
shared.evMaxSecs = 30; QProcess::execute("rm", {shared.outDir + "/stream.m3u8"});
shared.buffPath = QDir::tempPath(); QProcess::execute("rm", {shared.tmpDir + "/events"});
shared.webRoot = "/var/www/html";
shared.webBg = "#485564";
shared.webTxt = "#dee5ee";
shared.webFont = "courier";
} }
int Camera::start(const QStringList &args) int Camera::start(const QStringList &args)
{ {
shared.conf = getParam("-c", args); if (rdConf(getParam("-c", args), &shared))
{
QDir().mkpath(shared.outDir);
QDir().mkpath(shared.tmpDir);
if (rdConf(&shared)) QDir().mkpath(shared.outDir + "/events");
QDir().mkpath(shared.tmpDir + "/live");
QDir().mkpath(shared.tmpDir + "/logs");
QDir().mkpath(shared.tmpDir + "/img");
cleanup();
touch(shared.tmpDir + "/index.html");
touch(shared.tmpDir + "/stream.m3u8");
QProcess::execute("ln", {"-s", shared.tmpDir + "/live", shared.outDir + "/live"});
QProcess::execute("ln", {"-s", shared.tmpDir + "/logs", shared.outDir + "/logs"});
QProcess::execute("ln", {"-s", shared.tmpDir + "/img", shared.outDir + "/img"});
QProcess::execute("ln", {"-s", shared.tmpDir + "/index.html", shared.outDir + "/index.html"});
QProcess::execute("ln", {"-s", shared.tmpDir + "/stream.m3u8", shared.outDir + "/stream.m3u8"});
QProcess::execute("ln", {"-s", shared.outDir + "/events", shared.tmpDir + "/events"});
if (!QDir::setCurrent(shared.tmpDir))
{
QTextStream(stderr) << "err: failed to change/create the current working directory to camera folder: '" << shared.outDir << "' does it exists?" << Qt::endl;
shared.retCode = ENOENT;
}
else
{ {
auto thr1 = new QThread(nullptr); auto thr1 = new QThread(nullptr);
auto thr2 = new QThread(nullptr); auto thr2 = new QThread(nullptr);
@ -53,6 +71,7 @@ int Camera::start(const QStringList &args)
thr3->start(); thr3->start();
thr4->start(); thr4->start();
} }
}
return shared.retCode; return shared.retCode;
} }
@ -133,7 +152,6 @@ void RecLoop::updateCmd()
recArgs << "-hls_list_size" << "1000"; recArgs << "-hls_list_size" << "1000";
recArgs << "-hls_flags" << "append_list+omit_endlist"; recArgs << "-hls_flags" << "append_list+omit_endlist";
recArgs << "-rtsp_transport" << "tcp"; recArgs << "-rtsp_transport" << "tcp";
recArgs << "-stimeout" << "3000";
recArgs << "-t" << QString::number(heartBeat); recArgs << "-t" << QString::number(heartBeat);
recArgs << "stream.m3u8"; recArgs << "stream.m3u8";
@ -143,7 +161,6 @@ void RecLoop::updateCmd()
imgArgs << "-strftime_mkdir" << "1"; imgArgs << "-strftime_mkdir" << "1";
imgArgs << "-vf" << "fps=1,scale=320:240"; imgArgs << "-vf" << "fps=1,scale=320:240";
imgArgs << "-rtsp_transport" << "tcp"; imgArgs << "-rtsp_transport" << "tcp";
imgArgs << "-stimeout" << "3000";
imgArgs << "-t" << QString::number(heartBeat); imgArgs << "-t" << QString::number(heartBeat);
imgArgs << "img/" + QString(STRFTIME_FMT) + ".bmp"; imgArgs << "img/" + QString(STRFTIME_FMT) + ".bmp";
@ -224,10 +241,9 @@ bool Upkeep::exec()
enforceMaxImages(); enforceMaxImages();
enforceMaxVids(); enforceMaxVids();
genHTMLul(".", shared->camName, shared); genFrontPage(shared);
genCSS(shared); genCSS(shared);
genHTMLul(shared->webRoot, QString(APP_NAME) + " " + QString(APP_VER), shared); genCamPage(shared);
return Loop::exec(); return Loop::exec();
} }
@ -417,20 +433,63 @@ void DetectLoop::pcBreak()
else delayCycles += 5; else delayCycles += 5;
detLog("no motion detected, running post command: " + shared->postCmd, shared); detLog("no motion detected, running post command: " + shared->postCmd, shared);
system(shared->postCmd.toUtf8().data());
auto args = parseArgs(shared->postCmd.toUtf8(), -1);
if (args.isEmpty())
{
detLog("err: did not parse an executable from the post command line.", shared);
}
else
{
QProcess::execute(args[0], args.mid(1));
}
} }
} }
mod = false; mod = false;
} }
float DetectLoop::getFloatFromExe(const QByteArray &line)
{
QString strLine(line);
QString strNum;
for (auto chr : strLine)
{
if (chr.isDigit() || (chr == '.'))
{
strNum.append(chr);
}
else
{
break;
}
}
return strNum.toFloat();
}
QStringList DetectLoop::buildArgs(const QString &prev, const QString &next)
{
auto args = parseArgs(shared->compCmd.toUtf8(), -1);
for (auto i = 0; i < args.size(); ++i)
{
if (args[i] == PREV_IMG) args[i] = prev;
if (args[i] == NEXT_IMG) args[i] = next;
}
return args;
}
bool DetectLoop::exec() bool DetectLoop::exec()
{ {
if (delayCycles > 0) if (delayCycles > 0)
{ {
delayCycles -= 1; delayCycles -= 1;
detLog("spec: detection cycle skipped. cycles left to be skipped: " + QString::number(delayCycles), shared); detLog("delay: detection cycle skipped. cycles left to be skipped: " + QString::number(delayCycles), shared);
} }
else else
{ {
@ -444,27 +503,43 @@ bool DetectLoop::exec()
} }
else else
{ {
QProcess extComp;
QStringList args;
auto pos = images.size() - 1; auto pos = images.size() - 1;
auto args = buildArgs(images[pos - 1], images[pos]);
args << "compare"; if (args.isEmpty())
args << "-metric" << "FUZZ"; {
args << images[pos - 1]; detLog("err: could not parse a executable name from img_comp_cmd: " + shared->compCmd, shared);
args << images[pos]; }
args << "/dev/null"; else
{
QProcess extComp;
extComp.start("magick", args); extComp.start(args[0], args.mid(1));
extComp.waitForFinished(); extComp.waitForFinished();
QString output = extComp.readAllStandardError(); float score = 0;
auto ok = true;
output = output.left(output.indexOf(' ')); if (shared->outputType == "stdout")
{
score = getFloatFromExe(extComp.readAllStandardOutput());
}
else if (shared->outputType == "stderr")
{
score = getFloatFromExe(extComp.readAllStandardError());
}
else
{
ok = false;
}
detLog(extComp.program() + " " + args.join(" ") + " --result: " + output, shared); if (!ok)
{
auto score = output.toFloat(); detLog("err: img_comp_out: " + shared->outputType + " is not valid. it must be 'stdout' or 'stderr'" , shared);
}
else
{
detLog(args.join(" ") + " --result: " + QString::number(score), shared);
if (score >= shared->imgThresh) if (score >= shared->imgThresh)
{ {
@ -483,6 +558,8 @@ bool DetectLoop::exec()
} }
} }
} }
}
}
return Loop::exec(); return Loop::exec();
} }

View File

@ -25,6 +25,8 @@ private:
shared_t shared; shared_t shared;
void cleanup();
public: public:
explicit Camera(QObject *parent = nullptr); explicit Camera(QObject *parent = nullptr);
@ -126,6 +128,8 @@ private:
bool mod; bool mod;
void resetTimers(); void resetTimers();
float getFloatFromExe(const QByteArray &line);
QStringList buildArgs(const QString &prev, const QString &next);
private slots: private slots:

View File

@ -194,6 +194,26 @@ bool rdConf(const QString &filePath, shared_t *share)
} }
else else
{ {
share->recordUrl.clear();
share->postCmd.clear();
share->camName.clear();
share->retCode = 0;
share->imgThresh = 8000;
share->maxEvents = 100;
share->maxLogSize = 100000;
share->skipCmd = false;
share->postSecs = 60;
share->evMaxSecs = 30;
share->conf = filePath;
share->buffPath = "/var/opt/" + QString(APP_BIN) + "/buf";
share->webRoot = "/var/opt/" + QString(APP_BIN) + "/web";
share->webBg = "#485564";
share->webTxt = "#dee5ee";
share->webFont = "courier";
share->outputType = "stderr";
share->compCmd = "magick compare -metric FUZZ " + QString(PREV_IMG) + " " + QString(NEXT_IMG) + " /dev/null";
QString line; QString line;
do do
@ -215,91 +235,52 @@ bool rdConf(const QString &filePath, shared_t *share)
rdLine("img_thresh = ", line, &share->imgThresh); rdLine("img_thresh = ", line, &share->imgThresh);
rdLine("max_events = ", line, &share->maxEvents); rdLine("max_events = ", line, &share->maxEvents);
rdLine("max_log_size = ", line, &share->maxLogSize); rdLine("max_log_size = ", line, &share->maxLogSize);
rdLine("img_comp_out = ", line, &share->outputType);
rdLine("img_comp_cmd = ", line, &share->compCmd);
} }
} while(!line.isEmpty()); } while(!line.isEmpty());
}
return share->retCode == 0;
}
bool rdConf(shared_t *share)
{
if (rdConf(share->conf, share))
{
if (share->camName.isEmpty()) if (share->camName.isEmpty())
{ {
share->camName = QFileInfo(share->conf).fileName(); share->camName = QFileInfo(share->conf).fileName();
} }
share->outDir = QDir().cleanPath(share->webRoot) + "/" + share->camName; share->outDir = QDir().cleanPath(share->webRoot) + "/" + share->camName;
share->tmpDir = share->buffPath + "/" + APP_BIN + "/" + share->camName; share->tmpDir = share->buffPath + "/" + share->camName;
share->servPath = QString("/var/opt/") + APP_BIN + "/" + APP_BIN + "." + share->camName + ".service";
QDir().mkpath(share->outDir);
QDir().mkpath(share->tmpDir);
QDir().mkpath(share->outDir + "/events");
QDir().mkpath(share->tmpDir + "/live");
QDir().mkpath(share->tmpDir + "/logs");
QDir().mkpath(share->tmpDir + "/img");
touch(share->tmpDir + "/index.html");
touch(share->tmpDir + "/stream.html");
touch(share->tmpDir + "/stream.m3u8");
QFile::link(share->tmpDir + "/live", share->outDir + "/live");
QFile::link(share->tmpDir + "/logs", share->outDir + "/logs");
QFile::link(share->tmpDir + "/img", share->outDir + "/img");
QFile::link(share->tmpDir + "/index.html", share->outDir + "/index.html");
QFile::link(share->tmpDir + "/stream.html", share->outDir + "/stream.html");
QFile::link(share->tmpDir + "/stream.m3u8", share->outDir + "/stream.m3u8");
QFile::link(share->outDir + "/events", share->tmpDir + "/events");
if (!QDir::setCurrent(share->tmpDir))
{
QTextStream(stderr) << "err: failed to change/create the current working directory to camera folder: '" << share->outDir << "' does it exists?" << Qt::endl;
share->retCode = ENOENT;
}
} }
return share->retCode == 0; return share->retCode == 0;
} }
MultiInstance::MultiInstance(QObject *parent) : QObject(parent) {} void rmServices()
void MultiInstance::instStdout()
{ {
for (auto &&proc : procList) auto files = lsFilesInDir(QString("/var/opt/") + APP_BIN, ".service");
for (auto &&serv : files)
{ {
QTextStream(stdout) << proc->readAllStandardOutput(); QProcess::execute("systemctl", {"stop", serv});
QProcess::execute("systemctl", {"disable", serv});
QFile::remove(QString("/lib/systemd/system/") + serv);
QFile::remove(QString("/var/opt/") + APP_BIN + "/" + serv);
}
QProcess::execute("systemctl", {"daemon-reload"});
}
void listServices()
{
auto files = lsFilesInDir(QString("/var/opt/") + APP_BIN, ".service");
for (auto &&serv : files)
{
QTextStream(stdout) << serv << ": "; QProcess::execute("systemctl", {"is-active", serv});
} }
} }
void MultiInstance::instStderr() int loadServices(const QStringList &args)
{
for (auto &&proc : procList)
{
QTextStream(stderr) << proc->readAllStandardError();
}
}
void MultiInstance::procChanged(QProcess::ProcessState newState)
{
Q_UNUSED(newState)
for (auto &&proc : procList)
{
if (proc->state() == QProcess::Running)
{
return;
}
}
QCoreApplication::quit();
}
int MultiInstance::start(const QStringList &args)
{ {
auto ret = ENOENT; auto ret = ENOENT;
auto path = QDir().cleanPath(getParam("-d", args)); auto path = QDir().cleanPath(getParam("-d", args));
@ -307,37 +288,155 @@ int MultiInstance::start(const QStringList &args)
if (!QDir(path).exists()) if (!QDir(path).exists())
{ {
QTextStream(stderr) << "err: the supplied directory in -d '" << path << "' does not exists or is not a directory."; QTextStream(stderr) << "err: the supplied directory in -d '" << path << "' does not exists or is not a directory." << Qt::endl;
} }
else if (files.isEmpty()) else if (files.isEmpty())
{ {
QTextStream(stderr) << "err: no config files found in '" << path << "'"; QTextStream(stderr) << "err: no config files found in '" << path << "'" << Qt::endl;
} }
else else
{ {
ret = 0; ret = 0;
QTextStream(stdout) << "loading conf files from dir: " << path << Qt::endl;
for (auto &&conf : files) for (auto &&conf : files)
{ {
auto proc = new QProcess(this); shared_t shared;
QStringList subArgs; if (!rdConf(path + "/" + conf, &shared))
{
ret = shared.retCode; break;
}
else
{
QTextStream(stdout) << conf << " --" << Qt::endl;
subArgs << "-c" << path + "/" + conf; QFile file(shared.servPath);
connect(proc, &QProcess::readyReadStandardOutput, this, &MultiInstance::instStdout); if (!file.open(QFile::ReadWrite | QFile::Truncate))
connect(proc, &QProcess::readyReadStandardError, this, &MultiInstance::instStderr); {
connect(proc, &QProcess::stateChanged, this, &MultiInstance::procChanged); QTextStream(stderr) << "err: failed to open service file: " << shared.servPath << " for writing. reason: " << file.errorString();
connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, proc, &QProcess::terminate); ret = EACCES; file.close(); break;
}
else
{
file.write("[Unit]\n");
file.write("Description=" + QByteArray(APP_NAME) + " Camera - " + shared.camName.toUtf8() + "\n");
file.write("After=network.target\n\n");
file.write("[Service]\n");
file.write("Type=simple\n");
file.write("User=" + QByteArray(APP_BIN) + "\n");
file.write("Restart=always\n");
file.write("RestartSec=5\n");
file.write("TimeoutStopSec=infinity\n");
file.write("ExecStart=/usr/bin/env " + QByteArray(APP_BIN) + " -c " + shared.conf.toUtf8() + "\n\n");
file.write("[Install]\n");
file.write("WantedBy=multi-user.target");
file.close();
proc->setProgram(APP_BIN); auto servName = QFileInfo(shared.servPath).fileName();
proc->setArguments(subArgs);
proc->start();
procList.append(proc); if (!QFile::link(shared.servPath, "/lib/systemd/system/" + servName))
{
ret = EACCES; break;
}
else
{
if (ret == 0) ret = QProcess::execute("systemctl", {"daemon-reload"});
if (ret == 0) ret = QProcess::execute("systemctl", {"enable", servName});
if (ret == 0) ret = QProcess::execute("systemctl", {"start", servName});
if (ret != 0)
{
break;
}
else
{
QTextStream(stdout) << "Successfully loaded camera service: " << servName << Qt::endl;
}
}
}
}
} }
} }
return ret; return ret;
} }
QStringList parseArgs(const QByteArray &data, int maxArgs, int *pos)
{
QStringList ret;
QString arg;
auto line = QString::fromUtf8(data);
auto inDQuotes = false;
auto inSQuotes = false;
auto escaped = false;
if (pos != nullptr) *pos = 0;
for (int i = 0; i < line.size(); ++i)
{
if (pos != nullptr) *pos += 1;
if ((line[i] == '\'') && !inDQuotes && !escaped)
{
// single quote '
inSQuotes = !inSQuotes;
}
else if ((line[i] == '\"') && !inSQuotes && !escaped)
{
// double quote "
inDQuotes = !inDQuotes;
}
else
{
escaped = false;
if (line[i].isSpace() && !inDQuotes && !inSQuotes)
{
// space
if (!arg.isEmpty())
{
ret.append(arg);
arg.clear();
}
}
else
{
if ((line[i] == '\\') && ((i + 1) < line.size()))
{
if ((line[i + 1] == '\'') || (line[i + 1] == '\"'))
{
escaped = true;
}
else
{
arg.append(line[i]);
}
}
else
{
arg.append(line[i]);
}
}
}
if ((ret.size() >= maxArgs) && (maxArgs != -1))
{
break;
}
}
if (!arg.isEmpty() && !inDQuotes && !inSQuotes)
{
ret.append(arg);
}
return ret;
}

View File

@ -26,10 +26,11 @@
#include <QTimer> #include <QTimer>
#include <QStringList> #include <QStringList>
#include <QMutex> #include <QMutex>
#include <iostream>
using namespace std; using namespace std;
#define APP_VER "3.1" #define APP_VER "3.2"
#define APP_NAME "Motion Watch" #define APP_NAME "Motion Watch"
#define APP_BIN "mow" #define APP_BIN "mow"
#define REC_LOG_NAME "rec_log_lines.html" #define REC_LOG_NAME "rec_log_lines.html"
@ -39,6 +40,8 @@ using namespace std;
#define STRFTIME_FMT "%Y%m%d%H%M%S" #define STRFTIME_FMT "%Y%m%d%H%M%S"
#define MAX_IMAGES 1000 #define MAX_IMAGES 1000
#define MAX_VIDEOS 1000 #define MAX_VIDEOS 1000
#define PREV_IMG "&prev&"
#define NEXT_IMG "&next&"
struct evt_t struct evt_t
{ {
@ -66,6 +69,9 @@ struct shared_t
QString webTxt; QString webTxt;
QString webFont; QString webFont;
QString webRoot; QString webRoot;
QString servPath;
QString outputType;
QString compCmd;
bool skipCmd; bool skipCmd;
int evMaxSecs; int evMaxSecs;
int postSecs; int postSecs;
@ -81,8 +87,11 @@ QStringList lsDirsInDir(const QString &path);
QStringList listFacingFiles(const QString &path, const QString &ext, const QDateTime &stamp, int secs, char dir); QStringList listFacingFiles(const QString &path, const QString &ext, const QDateTime &stamp, int secs, char dir);
QStringList backwardFacingFiles(const QString &path, const QString &ext, const QDateTime &stamp, int secs); QStringList backwardFacingFiles(const QString &path, const QString &ext, const QDateTime &stamp, int secs);
QStringList forwardFacingFiles(const QString &path, const QString &ext, const QDateTime &stamp, int secs); QStringList forwardFacingFiles(const QString &path, const QString &ext, const QDateTime &stamp, int secs);
QStringList parseArgs(const QByteArray &data, int maxArgs, int *pos = nullptr);
bool rdConf(const QString &filePath, shared_t *share); bool rdConf(const QString &filePath, shared_t *share);
bool rdConf(shared_t *share); int loadServices(const QStringList &args);
void listServices();
void rmServices();
void touch(const QString &path); void touch(const QString &path);
void rdLine(const QString &param, const QString &line, QString *value); void rdLine(const QString &param, const QString &line, QString *value);
void rdLine(const QString &param, const QString &line, int *value); void rdLine(const QString &param, const QString &line, int *value);
@ -90,25 +99,4 @@ void enforceMaxEvents(shared_t *share);
void enforceMaxImages(); void enforceMaxImages();
void enforceMaxVids(); void enforceMaxVids();
class MultiInstance : public QObject
{
Q_OBJECT
private:
QList<QProcess*> procList;
private slots:
void instStdout();
void instStderr();
void procChanged(QProcess::ProcessState newState);
public:
explicit MultiInstance(QObject *parent = nullptr);
int start(const QStringList &args);
};
#endif // COMMON_H #endif // COMMON_H

View File

@ -13,6 +13,27 @@
#include "common.h" #include "common.h"
#include "camera.h" #include "camera.h"
void showHelp()
{
QTextStream(stdout) << APP_NAME << " " << APP_VER << Qt::endl << Qt::endl;
QTextStream(stdout) << "Usage: mow <argument>" << Qt::endl << Qt::endl;
QTextStream(stdout) << "-h : display usage information about this application." << Qt::endl;
QTextStream(stdout) << "-c : path to the config file used to run a single camera instance." << Qt::endl;
QTextStream(stdout) << "-d : path to a directory that can contain multiple config files." << Qt::endl;
QTextStream(stdout) << " each file found in the directory will be used to create a " << Qt::endl;
QTextStream(stdout) << " systemd service for the camera. already existing camera" << Qt::endl;
QTextStream(stdout) << " services will be reloaded and restarted. any services without" << Qt::endl;
QTextStream(stdout) << " a config file will be removed." << Qt::endl;
QTextStream(stdout) << "-i : this is the same as -d except a directory does not need to be" << Qt::endl;
QTextStream(stdout) << " provided. the default directory /etc/" << APP_BIN << " will be used." << Qt::endl;
QTextStream(stdout) << "-v : display the current version." << Qt::endl;
QTextStream(stdout) << "-u : uninstall the entire app from your system, including all" << Qt::endl;
QTextStream(stdout) << " camera services." << Qt::endl;
QTextStream(stdout) << "-f : force an action without pausing for user confirmation." << Qt::endl;
QTextStream(stdout) << "-l : list all camera services along with statuses." << Qt::endl;
QTextStream(stdout) << "-r : remove all camera services." << Qt::endl;
}
int main(int argc, char** argv) int main(int argc, char** argv)
{ {
QCoreApplication app(argc, argv); QCoreApplication app(argc, argv);
@ -25,14 +46,7 @@ int main(int argc, char** argv)
if (args.contains("-h")) if (args.contains("-h"))
{ {
QTextStream(stdout) << "Motion Watch " << APP_VER << Qt::endl << Qt::endl; showHelp();
QTextStream(stdout) << "Usage: mow <argument>" << Qt::endl << Qt::endl;
QTextStream(stdout) << "-h : display usage information about this application." << Qt::endl;
QTextStream(stdout) << "-c : path to the config file used to run a single camera instance." << Qt::endl;
QTextStream(stdout) << "-d : path to a directory that can contain multiple config files." << Qt::endl;
QTextStream(stdout) << " each file found in the directory will be used to run a" << Qt::endl;
QTextStream(stdout) << " camera instance." << Qt::endl;
QTextStream(stdout) << "-v : display the current version." << Qt::endl << Qt::endl;
} }
else if (args.contains("-v")) else if (args.contains("-v"))
{ {
@ -40,14 +54,19 @@ int main(int argc, char** argv)
} }
else if (args.contains("-d")) else if (args.contains("-d"))
{ {
auto *muli = new MultiInstance(&app); rmServices(); ret = loadServices(args);
ret = muli->start(args);
if (ret == 0)
{
ret = QCoreApplication::exec();
} }
else if (args.contains("-l"))
{
listServices();
}
else if (args.contains("-i"))
{
args.clear();
args.append("-d");
args.append("/etc/" + QString(APP_BIN));
rmServices(); ret = loadServices(args);
} }
else if (args.contains("-c")) else if (args.contains("-c"))
{ {
@ -60,9 +79,32 @@ int main(int argc, char** argv)
ret = QCoreApplication::exec(); ret = QCoreApplication::exec();
} }
} }
else if (args.contains("-u"))
{
if (args.contains("-f"))
{
rmServices(); QProcess::execute("/opt/mow/uninst", QStringList());
}
else else
{ {
QTextStream(stderr) << "err: no config file(s) were given in -c" << Qt::endl; char ans;
std::cout << "This will completely uninstall " << APP_NAME << " from your system. continue y/n? ";
std::cin >> ans;
if (ans == 'y' || ans == 'Y')
{
rmServices(); QProcess::execute("/opt/mow/uninst", QStringList());
}
}
}
else if (args.contains("-r"))
{
rmServices();
}
else
{
showHelp();
} }
return ret; return ret;

View File

@ -12,12 +12,8 @@
#include "web.h" #include "web.h"
void genHTMLul(const QString &outputDir, const QString &title, shared_t *share) void genFrontPage(shared_t *share)
{ {
QStringList logNames;
QStringList eveNames;
QStringList dirNames;
QString htmlText = "<!DOCTYPE html>\n"; QString htmlText = "<!DOCTYPE html>\n";
htmlText += "<html>\n"; htmlText += "<html>\n";
@ -29,12 +25,47 @@ void genHTMLul(const QString &outputDir, const QString &title, shared_t *share)
htmlText += "<link rel='stylesheet' href='/theme.css'>\n"; htmlText += "<link rel='stylesheet' href='/theme.css'>\n";
htmlText += "</head>\n"; htmlText += "</head>\n";
htmlText += "<body>\n"; htmlText += "<body>\n";
htmlText += "<h3>" + title + "</h3>\n"; htmlText += "<h3>" + QString(APP_NAME) + " " + QString(APP_VER) + "</h3>\n";
if (QDir().exists(outputDir + "/live")) auto dirNames = lsDirsInDir(share->buffPath);
htmlText += "<ul>\n";
for (auto &&dirName : dirNames)
{ {
eveNames = lsFilesInDir(outputDir + "/events", ".html"); htmlText += " <li><a href='" + dirName + "/index.html'>" + dirName + "</a></li>\n";
logNames = lsFilesInDir(outputDir + "/logs", "_log.html"); }
htmlText += "</ul>\n";
htmlText += "</body>\n";
htmlText += "</html>";
QFile outFile(QDir().cleanPath(share->buffPath) + "/index.html");
outFile.open(QFile::WriteOnly);
outFile.write(htmlText.toUtf8());
outFile.close();
}
void genCamPage(shared_t *share)
{
auto outputDir = QDir().cleanPath(share->tmpDir);
QString htmlText = "<!DOCTYPE html>\n";
htmlText += "<html>\n";
htmlText += "<head>\n";
htmlText += "<meta http-equiv=\"Cache-Control\" content=\"no-cache, no-store, must-revalidate\" />\n";
htmlText += "<meta http-equiv=\"Pragma\" content=\"no-cache\" />\n";
htmlText += "<meta http-equiv=\"Expires\" content=\"0\" />\n";
htmlText += "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n";
htmlText += "<link rel='stylesheet' href='/theme.css'>\n";
htmlText += "</head>\n";
htmlText += "<body>\n";
htmlText += "<h3>" + share->camName + "</h3>\n";
if (QDir().exists(outputDir + "/logs"))
{
auto logNames = lsFilesInDir(outputDir + "/logs", "_log.html");
htmlText += "<h4>Logs</h4>\n"; htmlText += "<h4>Logs</h4>\n";
htmlText += "<ul>\n"; htmlText += "<ul>\n";
@ -49,13 +80,20 @@ void genHTMLul(const QString &outputDir, const QString &title, shared_t *share)
} }
htmlText += "</ul>\n"; htmlText += "</ul>\n";
htmlText += "<h4>Live</h4>\n"; }
htmlText += "<ul>\n";
htmlText += " <li><a href='stream.html'>" + share->camName + ":live" + "</a></li>\n";
htmlText += "</ul>\n";
htmlText += "<h4>Motion Events</h4>\n";
genHTMLstream("stream"); if (QDir().exists(outputDir + "/live"))
{
htmlText += "<h4>Live</h4>\n";
genHTMLstream(htmlText);
}
if (QDir().exists(outputDir + "/events"))
{
auto eveNames = lsFilesInDir(outputDir + "/events", ".html");
htmlText += "<h4>Motion Events</h4>\n";
for (auto &&eveName : eveNames) for (auto &&eveName : eveNames)
{ {
@ -66,19 +104,6 @@ void genHTMLul(const QString &outputDir, const QString &title, shared_t *share)
htmlText += "<a href='events/" + eveName + "'><img src='events/" + name + ".jpg" + "' style='width:25%;height:25%;'</a>\n"; htmlText += "<a href='events/" + eveName + "'><img src='events/" + name + ".jpg" + "' style='width:25%;height:25%;'</a>\n";
} }
} }
else
{
dirNames = lsDirsInDir(outputDir);
htmlText += "<ul>\n";
for (auto &&dirName : dirNames)
{
htmlText += " <li><a href='" + dirName + "/index.html'>" + dirName + "</a></li>\n";
}
htmlText += "</ul>\n";
}
htmlText += "</body>\n"; htmlText += "</body>\n";
htmlText += "</html>"; htmlText += "</html>";
@ -90,51 +115,32 @@ void genHTMLul(const QString &outputDir, const QString &title, shared_t *share)
outFile.close(); outFile.close();
} }
void genHTMLstream(const QString &name) void genHTMLstream(QString &text)
{ {
QString htmlText = "<!DOCTYPE html>\n"; text += "<script src=\"/hls.js\">\n";
text += "</script>\n";
htmlText += "<html>\n"; text += "<video width=50% height=50% id=\"video\" controls>\n";
htmlText += "<head>\n"; text += "</video>\n";
htmlText += "<meta http-equiv=\"Cache-Control\" content=\"no-cache, no-store, must-revalidate\" />\n"; text += "<script>\n";
htmlText += "<meta http-equiv=\"Pragma\" content=\"no-cache\" />\n"; text += " var video = document.getElementById('video');\n";
htmlText += "<meta http-equiv=\"Expires\" content=\"0\" />\n"; text += " if (Hls.isSupported()) {\n";
htmlText += "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n"; text += " var hls = new Hls({\n";
htmlText += "<link rel='stylesheet' href='/theme.css'>\n"; text += " debug: true,\n";
htmlText += "</head>\n"; text += " });\n";
htmlText += "<body>\n"; text += " hls.loadSource('stream.m3u8');\n";
htmlText += " <script src=\"https://cdn.jsdelivr.net/npm/hls.js@1\">\n"; text += " hls.attachMedia(video);\n";
htmlText += " </script>\n"; text += " hls.on(Hls.Events.MEDIA_ATTACHED, function () {\n";
htmlText += " <video width=100% height=100% id=\"video\" controls>\n"; text += " video.muted = true;\n";
htmlText += " </video>\n"; text += " video.play();\n";
htmlText += " <script>\n"; text += " });\n";
htmlText += " var video = document.getElementById('video');\n"; text += " }\n";
htmlText += " if (Hls.isSupported()) {\n"; text += " else if (video.canPlayType('application/vnd.apple.mpegurl')) {\n";
htmlText += " var hls = new Hls({\n"; text += " video.src = 'stream.m3u8';\n";
htmlText += " debug: true,\n"; text += " video.addEventListener('canplay', function () {\n";
htmlText += " });\n"; text += " video.play();\n";
htmlText += " hls.loadSource('" + name + ".m3u8');\n"; text += " });\n";
htmlText += " hls.attachMedia(video);\n"; text += " }\n";
htmlText += " hls.on(Hls.Events.MEDIA_ATTACHED, function () {\n"; text += "</script>\n";
htmlText += " video.muted = true;\n";
htmlText += " video.play();\n";
htmlText += " });\n";
htmlText += " }\n";
htmlText += " else if (video.canPlayType('application/vnd.apple.mpegurl')) {\n";
htmlText += " video.src = '" + name + ".m3u8';\n";
htmlText += " video.addEventListener('canplay', function () {\n";
htmlText += " video.play();\n";
htmlText += " });\n";
htmlText += " }\n";
htmlText += " </script>\n";
htmlText += "</body>\n";
htmlText += "</html>";
QFile outFile(name + ".html");
outFile.open(QFile::WriteOnly);
outFile.write(htmlText.toUtf8());
outFile.close();
} }
void genHTMLvod(const QString &name) void genHTMLvod(const QString &name)
@ -175,7 +181,7 @@ void genCSS(shared_t *share)
cssText += " color: " + share->webTxt + ";\n"; cssText += " color: " + share->webTxt + ";\n";
cssText += "}\n"; cssText += "}\n";
QFile outFile(QDir().cleanPath(share->webRoot) + "/theme.css"); QFile outFile(QDir().cleanPath(share->buffPath) + "/theme.css");
outFile.open(QFile::WriteOnly); outFile.open(QFile::WriteOnly);
outFile.write(cssText.toUtf8()); outFile.write(cssText.toUtf8());

View File

@ -15,8 +15,9 @@
#include "common.h" #include "common.h"
void genHTMLul(const QString &outputDir, const QString &title, shared_t *share); void genFrontPage(shared_t *share);
void genHTMLstream(const QString &name); void genCamPage(shared_t *share);
void genHTMLstream(QString &text);
void genHTMLvod(const QString &name); void genHTMLvod(const QString &name);
void genCSS(shared_t *share); void genCSS(shared_t *share);