diff --git a/motioneye/extra/motioneye.conf.sample b/motioneye/extra/motioneye.conf.sample
index 80d5345ec..0822ca050 100644
--- a/motioneye/extra/motioneye.conf.sample
+++ b/motioneye/extra/motioneye.conf.sample
@@ -13,6 +13,11 @@ media_path /var/lib/motioneye
# the log level (use quiet, error, warning, info or debug)
log_level info
+# whether to write logs to files in log_path (motioneye.log, motion.log)
+# instead of standard error / standard output;
+# the log_path setting above only has an effect when this is enabled
+log_to_file false
+
# the IP address to listen on
# (0.0.0.0 for all interfaces, 127.0.0.1 for localhost)
listen 0.0.0.0
diff --git a/motioneye/handlers/log.py b/motioneye/handlers/log.py
index 0ca2babaa..3577831e8 100644
--- a/motioneye/handlers/log.py
+++ b/motioneye/handlers/log.py
@@ -39,6 +39,9 @@ def get(self, name):
path, filename = log
+ if path.startswith('/') and not os.path.exists(path):
+ raise HTTPError(404, 'log file not found')
+
self.set_header('Content-Type', 'text/plain')
self.set_header('Content-Disposition', 'attachment; filename=' + filename + ';')
diff --git a/motioneye/meyectl.py b/motioneye/meyectl.py
index e53732429..42fc29c15 100755
--- a/motioneye/meyectl.py
+++ b/motioneye/meyectl.py
@@ -101,6 +101,7 @@ def load_settings():
config_file = None
debug = False
+ log_to_file = False
for i in range(1, len(sys.argv)):
arg = sys.argv[i]
@@ -111,6 +112,9 @@ def load_settings():
elif arg == '-d':
debug = True
+ elif arg == '-l':
+ log_to_file = True
+
conf_path_given = [False]
run_path_given = [False]
log_path_given = [False]
@@ -202,9 +206,14 @@ def parse_conf_line(line):
if debug:
settings.LOG_LEVEL = logging.DEBUG
+ if log_to_file:
+ settings.LOG_TO_FILE = True
+
+
+def configure_logging(cmd, log_to_file=None):
+ if log_to_file is None:
+ log_to_file = settings.LOG_TO_FILE
-def configure_logging(cmd, log_to_file=False):
- sys.stderr.write(f'configure_logging cmd {cmd}: {log_to_file}\n')
if log_to_file or cmd != 'motioneye':
fmt = f'%(asctime)s: [{cmd}] %(levelname)8s: %(message)s'
@@ -221,7 +230,6 @@ def configure_logging(cmd, log_to_file=False):
else:
log_file = None
- sys.stderr.write(f'configure logging to file: {log_file}\n')
logging.basicConfig(
filename=log_file,
level=settings.LOG_LEVEL,
@@ -288,7 +296,7 @@ def make_arg_parser(command=None):
)
parser.add_argument(
'-l',
- help='log to file instead of standard error',
+ help='log to file instead of standard error, overriding the config file setting',
action='store_true',
dest='log_to_file',
)
diff --git a/motioneye/motionctl.py b/motioneye/motionctl.py
index 0479aae51..1ca3706e2 100644
--- a/motioneye/motionctl.py
+++ b/motioneye/motionctl.py
@@ -75,6 +75,17 @@ def find_motion():
return _motion_binary_cache
+def _get_motion_log_file():
+ """
+ Open motion's log file, or return None to let motion
+ inherit motionEye's stdout/stderr.
+ """
+ if not settings.LOG_TO_FILE:
+ return None
+
+ return open(join(settings.LOG_PATH, 'motion.log'), 'w')
+
+
def start(deferred=False):
from motioneye import config, mjpgclient
@@ -99,7 +110,6 @@ def start(deferred=False):
logging.debug(f'starting motion executable "{binary}" version "{version}"')
motion_cfg_path = join(settings.CONF_PATH, 'motion.conf')
- motion_log_path = join(settings.LOG_PATH, 'motion.log')
motion_pid_path = join(settings.RUN_PATH, 'motion.pid')
args = [binary, '-n', '-c', motion_cfg_path, '-d']
@@ -116,7 +126,7 @@ def start(deferred=False):
else: # fatal, quiet
args.append('1')
- log_file = open(motion_log_path, 'w')
+ log_file = _get_motion_log_file()
process = Popen(
args, stdout=log_file, stderr=log_file, close_fds=True, cwd=settings.CONF_PATH
diff --git a/motioneye/sendmail.py b/motioneye/sendmail.py
index d5e68041a..5f7b80153 100644
--- a/motioneye/sendmail.py
+++ b/motioneye/sendmail.py
@@ -200,7 +200,7 @@ def main(parser, args):
options = parse_options(parser, args)
- meyectl.configure_logging('sendmail', options.log_to_file)
+ meyectl.configure_logging('sendmail')
logging.debug('hello!')
diff --git a/motioneye/sendtelegram.py b/motioneye/sendtelegram.py
index 80ea0e16c..78ec0321e 100644
--- a/motioneye/sendtelegram.py
+++ b/motioneye/sendtelegram.py
@@ -146,7 +146,7 @@ def main(parser, args):
signal.signal(signal.SIGCHLD, signal.SIG_DFL)
options = parse_options(parser, args)
- meyectl.configure_logging('telegram', options.log_to_file)
+ meyectl.configure_logging('telegram')
logging.debug(options)
message = 'Motion has been detected by camera "%(camera)s/%(hostname)s" at %(moment)s (%(timezone)s).'
diff --git a/motioneye/server.py b/motioneye/server.py
index 785159d3b..975f8f11e 100644
--- a/motioneye/server.py
+++ b/motioneye/server.py
@@ -483,7 +483,12 @@ def main(parser, args, command):
options = parse_options(parser, args)
- meyectl.configure_logging('motioneye', options.background or options.log_to_file)
+ # daemon mode redirects stdout/stderr to /dev/null,
+ # so logging to file is the only way to preserve any output
+ if options.background:
+ settings.LOG_TO_FILE = True
+
+ meyectl.configure_logging('motioneye')
meyectl.configure_tornado()
if command == 'start':
diff --git a/motioneye/settings.py b/motioneye/settings.py
index c14d6f845..d3206b396 100644
--- a/motioneye/settings.py
+++ b/motioneye/settings.py
@@ -52,6 +52,10 @@
# the log level (use FATAL, ERROR, WARNING, INFO or DEBUG)
LOG_LEVEL = logging.INFO
+# whether to write logs to files in log_path
+# instead of standard error / standard output
+LOG_TO_FILE = False
+
# the IP address to listen on
# (0.0.0.0 for all interfaces, 127.0.0.1 for localhost)
LISTEN = '0.0.0.0'
diff --git a/motioneye/shell.py b/motioneye/shell.py
index 73857dcab..58097e074 100644
--- a/motioneye/shell.py
+++ b/motioneye/shell.py
@@ -27,7 +27,7 @@ def main(parser, args):
options = parse_options(parser, args)
- meyectl.configure_logging('shell', options.log_to_file)
+ meyectl.configure_logging('shell')
meyectl.configure_tornado()
logging.debug('hello!')
diff --git a/motioneye/webhook.py b/motioneye/webhook.py
index f7d5e3deb..52efdfbe1 100644
--- a/motioneye/webhook.py
+++ b/motioneye/webhook.py
@@ -35,7 +35,7 @@ def main(parser, args):
options = parse_options(parser, args)
- meyectl.configure_logging('webhook', options.log_to_file)
+ meyectl.configure_logging('webhook')
meyectl.configure_tornado()
logging.debug('hello!')
diff --git a/tests/test_handlers/test_log.py b/tests/test_handlers/test_log.py
new file mode 100644
index 000000000..953a7d417
--- /dev/null
+++ b/tests/test_handlers/test_log.py
@@ -0,0 +1,66 @@
+# Copyright (c) 2013 Calin Crisan
+# This file is part of motionEye.
+#
+# motionEye 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 3 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 .
+
+"""Tests verifying the log handler when the requested log file is absent."""
+
+import os
+import unittest
+from shutil import rmtree
+from tempfile import mkdtemp
+from unittest.mock import patch
+
+from motioneye.handlers.log import LogHandler
+from tests.test_handlers import HandlerTestCase
+
+
+class LogHandlerTest(HandlerTestCase):
+ handler_cls = LogHandler
+
+ def setUp(self):
+ self.log_dir = mkdtemp()
+ super().setUp()
+
+ def tearDown(self):
+ super().tearDown()
+ rmtree(self.log_dir)
+
+ def _fetch_motion_log(self):
+ cookie = self.make_session_cookie('admin')
+ return self.fetch('/log/motion/', headers={'Cookie': cookie})
+
+ def test_missing_log_file_returns_404(self):
+ # with log_to_file disabled motion.log is never written (#3330);
+ # the handler must not blow up trying to open it
+ path = os.path.join(self.log_dir, 'motion.log')
+ with patch.dict(LogHandler.LOGS, {'motion': (path, 'motion.log')}):
+ response = self._fetch_motion_log()
+
+ self.assertEqual(404, response.code)
+
+ def test_existing_log_file_is_served(self):
+ path = os.path.join(self.log_dir, 'motion.log')
+ with open(path, 'w') as f:
+ f.write('a motion log line\n')
+
+ with patch.dict(LogHandler.LOGS, {'motion': (path, 'motion.log')}):
+ response = self._fetch_motion_log()
+
+ self.assertEqual(200, response.code)
+ self.assertEqual(b'a motion log line\n', response.body)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_meyectl.py b/tests/test_meyectl.py
new file mode 100644
index 000000000..14c28e93e
--- /dev/null
+++ b/tests/test_meyectl.py
@@ -0,0 +1,81 @@
+# Copyright (c) 2013 Calin Crisan
+# This file is part of motionEye.
+#
+# motionEye 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 3 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 .
+
+"""Tests verifying that the log_to_file setting is read from the config file."""
+
+import os
+import sys
+import unittest
+from shutil import rmtree
+from tempfile import mkdtemp
+from unittest.mock import patch
+
+from motioneye import meyectl, settings
+
+
+class LoadSettingsLogToFileTest(unittest.TestCase):
+ # load_settings() assigns to these module globals directly, so patching
+ # cannot undo it; snapshot and restore them to keep other tests isolated
+ _SETTINGS = (
+ 'CONF_PATH',
+ 'RUN_PATH',
+ 'LOG_PATH',
+ 'MEDIA_PATH',
+ 'LOG_LEVEL',
+ 'LOG_TO_FILE',
+ 'config_file',
+ )
+
+ def setUp(self):
+ self.conf_dir = mkdtemp()
+ self.saved = {name: getattr(settings, name) for name in self._SETTINGS}
+
+ def tearDown(self):
+ for name, value in self.saved.items():
+ setattr(settings, name, value)
+
+ rmtree(self.conf_dir)
+
+ def _load_settings(self, conf, argv=()):
+ conf_file = os.path.join(self.conf_dir, 'motioneye.conf')
+ with open(conf_file, 'w') as f:
+ f.write(conf)
+
+ with patch.object(
+ sys, 'argv', ['meyectl', 'startserver', '-c', conf_file, *argv]
+ ):
+ meyectl.load_settings()
+
+ def test_log_to_file_disabled_by_default(self):
+ self._load_settings('log_level info\n')
+ self.assertEqual(False, settings.LOG_TO_FILE)
+
+ def test_log_to_file_false_from_config(self):
+ # the string is parsed as a bool, not fed to int() (#3330)
+ self._load_settings('log_to_file false\n')
+ self.assertEqual(False, settings.LOG_TO_FILE)
+
+ def test_log_to_file_true_from_config(self):
+ self._load_settings('log_to_file true\n')
+ self.assertEqual(True, settings.LOG_TO_FILE)
+
+ def test_l_argument_overrides_config(self):
+ self._load_settings('log_to_file false\n', argv=('-l',))
+ self.assertEqual(True, settings.LOG_TO_FILE)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_motionctl.py b/tests/test_motionctl.py
new file mode 100644
index 000000000..9a98f5bef
--- /dev/null
+++ b/tests/test_motionctl.py
@@ -0,0 +1,53 @@
+# Copyright (c) 2013 Calin Crisan
+# This file is part of motionEye.
+#
+# motionEye 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 3 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 .
+
+"""Tests verifying where motion's output is sent, depending on log_to_file."""
+
+import os
+import unittest
+from shutil import rmtree
+from tempfile import mkdtemp
+from unittest.mock import patch
+
+from motioneye import motionctl, settings
+
+
+class MotionLogFileTest(unittest.TestCase):
+ def setUp(self):
+ self.log_dir = mkdtemp()
+
+ def tearDown(self):
+ rmtree(self.log_dir)
+
+ def test_no_log_file_when_log_to_file_disabled(self):
+ # None makes Popen pass motionEye's own stdout/stderr on to motion
+ with patch.object(settings, 'LOG_TO_FILE', False):
+ self.assertIsNone(motionctl._get_motion_log_file())
+
+ def test_log_file_opened_when_log_to_file_enabled(self):
+ with patch.object(settings, 'LOG_TO_FILE', True), patch.object(
+ settings, 'LOG_PATH', self.log_dir
+ ):
+ log_file = motionctl._get_motion_log_file()
+
+ try:
+ self.assertEqual(os.path.join(self.log_dir, 'motion.log'), log_file.name)
+ finally:
+ log_file.close()
+
+
+if __name__ == '__main__':
+ unittest.main()