summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEvgeny Zinoviev <me@ch1p.io>2022-06-08 22:01:22 +0300
committerEvgeny Zinoviev <me@ch1p.io>2022-06-11 19:05:51 +0300
commiteb502ab9c94cc8a89a29f9310e2f56404b432053 (patch)
tree70ec81d40a9fc406960d85773436d3b33a014c7a
parent1ed87f69878b85daf94cde4c7b187939d9e15778 (diff)
ipcam: rewrite motion detection system
-rw-r--r--.gitignore4
-rw-r--r--doc/ipcam_motion_worker.md22
-rw-r--r--doc/ipcam_server.md24
-rw-r--r--requirements.txt3
-rw-r--r--src/home/bot/store.py78
-rw-r--r--src/home/camera/util.py100
-rw-r--r--src/home/config/config.py52
-rw-r--r--src/home/database/sqlite.py62
-rw-r--r--src/home/http/__init__.py2
-rw-r--r--src/home/http/http.py92
-rw-r--r--src/home/soundsensor/server.py49
-rw-r--r--src/home/util.py3
-rwxr-xr-xsrc/inverter_bot.py2
-rwxr-xr-xsrc/ipcam_server.py369
-rwxr-xr-xsrc/sound_node.py70
-rwxr-xr-xsrc/test/test_inverter_monitor.py4
-rw-r--r--systemd/ipcam_server.service13
-rw-r--r--systemd/sound_bot.service2
-rw-r--r--systemd/sound_node.service2
-rw-r--r--systemd/sound_sensor_node.service2
-rw-r--r--systemd/sound_sensor_server.service2
-rwxr-xr-xtools/ipcam_motion_worker.sh236
-rwxr-xr-xtools/ipcam_motion_worker_multiple.sh49
-rw-r--r--tools/lib.bash122
-rwxr-xr-xtools/process-motion-timecodes.py26
-rwxr-xr-xtools/video-util.sh94
26 files changed, 1199 insertions, 285 deletions
diff --git a/.gitignore b/.gitignore
index 2c719b5..4bbddf0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,8 +6,10 @@ __pycache__
/src/test/test_inverter_monitor.log
/esp32-cam/CameraWebServer/wifi_password.h
+*.swp
+
/localwebsite/vendor
/localwebsite/.debug.log
/localwebsite/config.local.php
/localwebsite/cache
-/localwebsite/test.php \ No newline at end of file
+/localwebsite/test.php
diff --git a/doc/ipcam_motion_worker.md b/doc/ipcam_motion_worker.md
new file mode 100644
index 0000000..3974739
--- /dev/null
+++ b/doc/ipcam_motion_worker.md
@@ -0,0 +1,22 @@
+local worker config example:
+```
+api_url=http://ip:port
+camera=1
+threshold=1
+```
+
+remote worker config example:
+```
+api_url=http://ip:port
+camera=1
+threshold=1
+fs_root=/var/ipcam_motion_fs
+fs_max_filesize=146800640
+```
+
+optional fields:
+```
+roi_file=roi.txt
+```
+
+`/var/ipcam_motion_fs` should be a tmpfs mountpoint \ No newline at end of file
diff --git a/doc/ipcam_server.md b/doc/ipcam_server.md
new file mode 100644
index 0000000..3c56646
--- /dev/null
+++ b/doc/ipcam_server.md
@@ -0,0 +1,24 @@
+config example (yaml)
+
+```
+server:
+ listen: 0.0.0.0:8320
+
+camera:
+ 1:
+ recordings_path: "/data1/cam-1"
+ motion_path: "/data1/cam-1/motion"
+ 2:
+ recordings_path: "/data2/cam-2"
+ motion_path: "/data2/cam-2/motion"
+ 3:
+ recordings_path: "/data3/cam-3"
+ motion_path: "/data3/cam-3/motion"
+
+motion:
+ padding: 2
+
+logging:
+ verbose: true
+
+``` \ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index 54fdf1e..9782e86 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -7,11 +7,12 @@ mysql-connector-python~=8.0.27
Werkzeug~=2.0.2
uwsgi~=2.0.20
python-telegram-bot~=13.1
-inverterd~=1.0.2
requests~=2.26.0
aiohttp~=3.8.1
pytz~=2021.3
PyYAML~=6.0
+apscheduler~=3.9.1
+psutil~=5.9.1
# following can be installed from debian repositories
# matplotlib~=3.5.0
diff --git a/src/home/bot/store.py b/src/home/bot/store.py
index aeedc47..e655d8f 100644
--- a/src/home/bot/store.py
+++ b/src/home/bot/store.py
@@ -1,80 +1,32 @@
-import sqlite3
-import os.path
-import logging
+from ..database.sqlite import SQLiteBase
-from ..config import config
-
-logger = logging.getLogger(__name__)
-
-
-def _get_database_path() -> str:
- return os.path.join(os.environ['HOME'], '.config', config.app_name, 'bot.db')
-
-
-class Store:
- SCHEMA_VERSION = 1
+class Store(SQLiteBase):
def __init__(self):
- self.sqlite = sqlite3.connect(_get_database_path(), check_same_thread=False)
-
- sqlite_version = self._get_sqlite_version()
- logger.info(f'SQLite version: {sqlite_version}')
-
- schema_version = self._get_schema_version()
- logger.info(f'Schema version: {schema_version}')
-
- if schema_version < 1:
- self._database_init()
- elif schema_version < Store.SCHEMA_VERSION:
- self._database_upgrade(Store.SCHEMA_VERSION)
-
- def __del__(self):
- if self.sqlite:
- self.sqlite.commit()
- self.sqlite.close()
-
- def _get_sqlite_version(self) -> str:
- cursor = self.sqlite.cursor()
- cursor.execute("SELECT sqlite_version()")
-
- return cursor.fetchone()[0]
-
- def _get_schema_version(self) -> int:
- cursor = self.sqlite.execute('PRAGMA user_version')
- return int(cursor.fetchone()[0])
-
- def _set_schema_version(self, v) -> None:
- self.sqlite.execute('PRAGMA user_version={:d}'.format(v))
- logger.info(f'Schema set to {v}')
-
- def _database_init(self) -> None:
- cursor = self.sqlite.cursor()
- cursor.execute("""CREATE TABLE IF NOT EXISTS users (
- id INTEGER PRIMARY KEY,
- lang TEXT NOT NULL
- )""")
- self.sqlite.commit()
- self._set_schema_version(1)
-
- def _database_upgrade(self, version: int) -> None:
- # do the upgrade here
+ super().__init__()
- # self.sqlite.commit()
- self._set_schema_version(version)
+ def schema_init(self, version: int) -> None:
+ if version < 1:
+ cursor = self.cursor()
+ cursor.execute("""CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY,
+ lang TEXT NOT NULL
+ )""")
+ self.commit()
def get_user_lang(self, user_id: int, default: str = 'en') -> str:
- cursor = self.sqlite.cursor()
+ cursor = self.cursor()
cursor.execute('SELECT lang FROM users WHERE id=?', (user_id,))
row = cursor.fetchone()
if row is None:
cursor.execute('INSERT INTO users (id, lang) VALUES (?, ?)', (user_id, default))
- self.sqlite.commit()
+ self.commit()
return default
else:
return row[0]
def set_user_lang(self, user_id: int, lang: str) -> None:
- cursor = self.sqlite.cursor()
+ cursor = self.cursor()
cursor.execute('UPDATE users SET lang=? WHERE id=?', (lang, user_id))
- self.sqlite.commit() \ No newline at end of file
+ self.commit()
diff --git a/src/home/camera/util.py b/src/home/camera/util.py
new file mode 100644
index 0000000..5f18a1f
--- /dev/null
+++ b/src/home/camera/util.py
@@ -0,0 +1,100 @@
+import asyncio
+import os.path
+import logging
+import psutil
+
+from ..util import chunks
+from ..config import config
+
+_logger = logging.getLogger(__name__)
+_temporary_fixing = '.temporary_fixing.mp4'
+
+
+def _get_ffmpeg_path() -> str:
+ return 'ffmpeg' if 'ffmpeg' not in config else config['ffmpeg']['path']
+
+
+def time2seconds(time: str) -> int:
+ time, frac = time.split('.')
+ frac = int(frac)
+
+ h, m, s = [int(i) for i in time.split(':')]
+
+ return round(s + m*60 + h*3600 + frac/1000)
+
+
+async def ffmpeg_recreate(filename: str):
+ filedir = os.path.dirname(filename)
+ tempname = os.path.join(filedir, _temporary_fixing)
+ mtime = os.path.getmtime(filename)
+
+ args = [_get_ffmpeg_path(), '-nostats', '-loglevel', 'error', '-i', filename, '-c', 'copy', '-y', tempname]
+ proc = await asyncio.create_subprocess_exec(*args,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE)
+ stdout, stderr = await proc.communicate()
+ if proc.returncode != 0:
+ _logger.error(f'fix_timestamps({filename}): ffmpeg returned {proc.returncode}, stderr: {stderr.decode().strip()}')
+
+ if os.path.isfile(tempname):
+ os.unlink(filename)
+ os.rename(tempname, filename)
+ os.utime(filename, (mtime, mtime))
+ _logger.info(f'fix_timestamps({filename}): OK')
+ else:
+ _logger.error(f'fix_timestamps({filename}): temp file \'{tempname}\' does not exists, fix failed')
+
+
+async def ffmpeg_cut(input: str,
+ output: str,
+ start_pos: int,
+ duration: int):
+ args = [_get_ffmpeg_path(), '-nostats', '-loglevel', 'error', '-i', input,
+ '-ss', str(start_pos), '-t', str(duration),
+ '-c', 'copy', '-y', output]
+ proc = await asyncio.create_subprocess_exec(*args,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE)
+ stdout, stderr = await proc.communicate()
+ if proc.returncode != 0:
+ _logger.error(f'ffmpeg_cut({input}, start_pos={start_pos}, duration={duration}): ffmpeg returned {proc.returncode}, stderr: {stderr.decode().strip()}')
+ else:
+ _logger.info(f'ffmpeg_cut({input}): OK')
+
+
+def dvr_scan_timecodes(timecodes: str) -> list[tuple[int, int]]:
+ timecodes = timecodes.split(',')
+ if len(timecodes) % 2 != 0:
+ raise ValueError('invalid number of timecodes')
+
+ timecodes = list(map(time2seconds, timecodes))
+ timecodes = list(chunks(timecodes, 2))
+
+ # sort out invalid fragments (dvr-scan returns them sometimes, idk why...)
+ timecodes = list(filter(lambda f: f[0] < f[1], timecodes))
+ if not timecodes:
+ raise ValueError('no valid timecodes')
+
+ # https://stackoverflow.com/a/43600953
+ timecodes.sort(key=lambda interval: interval[0])
+ merged = [timecodes[0]]
+ for current in timecodes:
+ previous = merged[-1]
+ if current[0] <= previous[1]:
+ previous[1] = max(previous[1], current[1])
+ else:
+ merged.append(current)
+
+ return merged
+
+
+def has_handle(fpath):
+ for proc in psutil.process_iter():
+ try:
+ for item in proc.open_files():
+ if fpath == item.path:
+ return True
+ except Exception:
+ pass
+
+ return False \ No newline at end of file
diff --git a/src/home/config/config.py b/src/home/config/config.py
index af1c772..8b50609 100644
--- a/src/home/config/config.py
+++ b/src/home/config/config.py
@@ -120,6 +120,7 @@ def setup_logging(verbose=False, log_file=None, default_fmt=False):
logging_level = logging.INFO
if is_development_mode() or verbose:
logging_level = logging.DEBUG
+ _add_logging_level('TRACE', logging.DEBUG-5)
log_config = {'level': logging_level}
if not default_fmt:
@@ -130,3 +131,54 @@ def setup_logging(verbose=False, log_file=None, default_fmt=False):
log_config['encoding'] = 'utf-8'
logging.basicConfig(**log_config)
+
+
+# https://stackoverflow.com/questions/2183233/how-to-add-a-custom-loglevel-to-pythons-logging-facility/35804945#35804945
+def _add_logging_level(levelName, levelNum, methodName=None):
+ """
+ Comprehensively adds a new logging level to the `logging` module and the
+ currently configured logging class.
+
+ `levelName` becomes an attribute of the `logging` module with the value
+ `levelNum`. `methodName` becomes a convenience method for both `logging`
+ itself and the class returned by `logging.getLoggerClass()` (usually just
+ `logging.Logger`). If `methodName` is not specified, `levelName.lower()` is
+ used.
+
+ To avoid accidental clobberings of existing attributes, this method will
+ raise an `AttributeError` if the level name is already an attribute of the
+ `logging` module or if the method name is already present
+
+ Example
+ -------
+ >>> addLoggingLevel('TRACE', logging.DEBUG - 5)
+ >>> logging.getLogger(__name__).setLevel("TRACE")
+ >>> logging.getLogger(__name__).trace('that worked')
+ >>> logging.trace('so did this')
+ >>> logging.TRACE
+ 5
+
+ """
+ if not methodName:
+ methodName = levelName.lower()
+
+ if hasattr(logging, levelName):
+ raise AttributeError('{} already defined in logging module'.format(levelName))
+ if hasattr(logging, methodName):
+ raise AttributeError('{} already defined in logging module'.format(methodName))
+ if hasattr(logging.getLoggerClass(), methodName):
+ raise AttributeError('{} already defined in logger class'.format(methodName))
+
+ # This method was inspired by the answers to Stack Overflow post
+ # http://stackoverflow.com/q/2183233/2988730, especially
+ # http://stackoverflow.com/a/13638084/2988730
+ def logForLevel(self, message, *args, **kwargs):
+ if self.isEnabledFor(levelNum):
+ self._log(levelNum, message, args, **kwargs)
+ def logToRoot(message, *args, **kwargs):
+ logging.log(levelNum, message, *args, **kwargs)
+
+ logging.addLevelName(levelNum, levelName)
+ setattr(logging, levelName, levelNum)
+ setattr(logging.getLoggerClass(), methodName, logForLevel)
+ setattr(logging, methodName, logToRoot) \ No newline at end of file
diff --git a/src/home/database/sqlite.py b/src/home/database/sqlite.py
new file mode 100644
index 0000000..8f1763e
--- /dev/null
+++ b/src/home/database/sqlite.py
@@ -0,0 +1,62 @@
+import sqlite3
+import os.path
+import logging
+
+from ..config import config, is_development_mode
+
+
+def _get_database_path(name) -> str:
+ return os.path.join(os.environ['HOME'], '.config', name, 'bot.db')
+
+
+class SQLiteBase:
+ SCHEMA = 1
+
+ def __init__(self, name=None, check_same_thread=False):
+ if not name:
+ name = config.app_name
+
+ self.logger = logging.getLogger(self.__class__.__name__)
+ self.sqlite = sqlite3.connect(_get_database_path(name),
+ check_same_thread=check_same_thread)
+
+ if is_development_mode():
+ self.sql_logger = logging.getLogger(self.__class__.__name__)
+ self.sql_logger.setLevel('TRACE')
+ self.sqlite.set_trace_callback(self.sql_logger.trace)
+
+ sqlite_version = self._get_sqlite_version()
+ self.logger.debug(f'SQLite version: {sqlite_version}')
+
+ schema_version = self.schema_get_version()
+ self.logger.debug(f'Schema version: {schema_version}')
+
+ self.schema_init(schema_version)
+ self.schema_set_version(self.SCHEMA)
+
+ def __del__(self):
+ if self.sqlite:
+ self.sqlite.commit()
+ self.sqlite.close()
+
+ def _get_sqlite_version(self) -> str:
+ cursor = self.sqlite.cursor()
+ cursor.execute("SELECT sqlite_version()")
+ return cursor.fetchone()[0]
+
+ def schema_get_version(self) -> int:
+ cursor = self.sqlite.execute('PRAGMA user_version')
+ return int(cursor.fetchone()[0])
+
+ def schema_set_version(self, v) -> None:
+ self.sqlite.execute('PRAGMA user_version={:d}'.format(v))
+ self.logger.info(f'Schema set to {v}')
+
+ def cursor(self) -> sqlite3.Cursor:
+ return self.sqlite.cursor()
+
+ def commit(self) -> None:
+ return self.sqlite.commit()
+
+ def schema_init(self, version: int) -> None:
+ raise ValueError(f'{self.__class__.__name__}: must override schema_init')
diff --git a/src/home/http/__init__.py b/src/home/http/__init__.py
new file mode 100644
index 0000000..2597457
--- /dev/null
+++ b/src/home/http/__init__.py
@@ -0,0 +1,2 @@
+from .http import serve, ok, routes, HTTPServer
+from aiohttp.web import FileResponse, Request
diff --git a/src/home/http/http.py b/src/home/http/http.py
new file mode 100644
index 0000000..acacb51
--- /dev/null
+++ b/src/home/http/http.py
@@ -0,0 +1,92 @@
+import logging
+import asyncio
+
+from aiohttp import web
+from aiohttp.web_exceptions import HTTPNotFound
+
+from ..util import stringify, format_tb, Addr
+
+
+@web.middleware
+async def errors_handler_middleware(request, handler):
+ try:
+ response = await handler(request)
+ return response
+
+ except HTTPNotFound:
+ return web.json_response({'error': 'not found'}, status=404)
+
+ except Exception as exc:
+ data = {
+ 'error': exc.__class__.__name__,
+ 'message': exc.message if hasattr(exc, 'message') else str(exc)
+ }
+ tb = format_tb(exc)
+ if tb:
+ data['stacktrace'] = tb
+
+ return web.json_response(data, status=500)
+
+
+def serve(addr: Addr, route_table: web.RouteTableDef, handle_signals: bool = True):
+ app = web.Application()
+ app.add_routes(route_table)
+ app.middlewares.append(errors_handler_middleware)
+
+ host, port = addr
+
+ web.run_app(app,
+ host=host,
+ port=port,
+ handle_signals=handle_signals)
+
+
+def routes() -> web.RouteTableDef:
+ return web.RouteTableDef()
+
+
+def ok(data=None):
+ if data is None:
+ data = 1
+ response = {'response': data}
+ return web.json_response(response, dumps=stringify)
+
+
+class HTTPServer:
+ def __init__(self, addr: Addr, handle_errors=True):
+ self.addr = addr
+ self.app = web.Application()
+ self.logger = logging.getLogger(self.__class__.__name__)
+
+ if handle_errors:
+ self.app.middlewares.append(errors_handler_middleware)
+
+ def _add_route(self,
+ method: str,
+ path: str,
+ handler: callable):
+ self.app.router.add_routes([getattr(web, method)(path, handler)])
+
+ def get(self, path, handler):
+ self._add_route('get', path, handler)
+
+ def post(self, path, handler):
+ self._add_route('post', path, handler)
+
+ def run(self, event_loop=None, handle_signals=True):
+ if not event_loop:
+ event_loop = asyncio.get_event_loop()
+
+ runner = web.AppRunner(self.app, handle_signals=handle_signals)
+ event_loop.run_until_complete(runner.setup())
+
+ host, port = self.addr
+ site = web.TCPSite(runner, host=host, port=port)
+ event_loop.run_until_complete(site.start())
+
+ self.logger.info(f'Server started at http://{host}:{port}')
+
+ event_loop.run_forever()
+
+ def ok(self, data=None):
+ return ok(data)
diff --git a/src/home/soundsensor/server.py b/src/home/soundsensor/server.py
index 490fc36..0a53ae6 100644
--- a/src/home/soundsensor/server.py
+++ b/src/home/soundsensor/server.py
@@ -4,13 +4,10 @@ import logging
import threading
from ..config import config
-from aiohttp import web
-from aiohttp.web_exceptions import (
- HTTPNotFound
-)
+from .. import http
from typing import Type
-from ..util import Addr, stringify, format_tb
+from ..util import Addr
logger = logging.getLogger(__name__)
@@ -74,52 +71,22 @@ class SoundSensorServer:
loop.run_forever()
def run_guard_server(self):
- routes = web.RouteTableDef()
-
- def ok(data=None):
- if data is None:
- data = 1
- response = {'response': data}
- return web.json_response(response, dumps=stringify)
-
- @web.middleware
- async def errors_handler_middleware(request, handler):
- try:
- response = await handler(request)
- return response
- except HTTPNotFound:
- return web.json_response({'error': 'not found'}, status=404)
- except Exception as exc:
- data = {
- 'error': exc.__class__.__name__,
- 'message': exc.message if hasattr(exc, 'message') else str(exc)
- }
- tb = format_tb(exc)
- if tb:
- data['stacktrace'] = tb
-
- return web.json_response(data, status=500)
+ routes = http.routes()
@routes.post('/guard/enable')
async def guard_enable(request):
self.set_recording(True)
- return ok()
+ return http.ok()
@routes.post('/guard/disable')
async def guard_disable(request):
self.set_recording(False)
- return ok()
+ return http.ok()
@routes.get('/guard/status')
async def guard_status(request):
- return ok({'enabled': self.is_recording_enabled()})
+ return http.ok({'enabled': self.is_recording_enabled()})
asyncio.set_event_loop(asyncio.new_event_loop()) # need to create new event loop in new thread
- app = web.Application()
- app.add_routes(routes)
- app.middlewares.append(errors_handler_middleware)
-
- web.run_app(app,
- host=self.addr[0],
- port=self.addr[1],
- handle_signals=False) # handle_signals=True doesn't work in separate thread
+ http.serve(self.addr, routes, handle_signals=False) # handle_signals=True doesn't work in separate thread
+
diff --git a/src/home/util.py b/src/home/util.py
index 2c43cb0..a6ac906 100644
--- a/src/home/util.py
+++ b/src/home/util.py
@@ -8,6 +8,7 @@ import logging
import string
import random
+from enum import Enum
from .config import config
from datetime import datetime
from typing import Tuple, Optional
@@ -28,6 +29,8 @@ def json_serial(obj):
"""JSON serializer for datetime objects"""
if isinstance(obj, datetime):
return obj.timestamp()
+ if isinstance(obj, Enum):
+ return obj.value
raise TypeError("Type %s not serializable" % type(obj))
diff --git a/src/inverter_bot.py b/src/inverter_bot.py
index 5ad5e33..5b3fe45 100755
--- a/src/inverter_bot.py
+++ b/src/inverter_bot.py
@@ -452,7 +452,7 @@ class InverterBot(Wrapper):
if __name__ == '__main__':
config.load('inverter_bot')
- inverter.init(host=config['inverter']['ip'], port=config['inverter']['port'])
+ inverter.schema_init(host=config['inverter']['ip'], port=config['inverter']['port'])
monitor = InverterMonitor()
monitor.set_charging_event_handler(monitor_charging)
diff --git a/src/ipcam_server.py b/src/ipcam_server.py
new file mode 100755
index 0000000..d212be2
--- /dev/null
+++ b/src/ipcam_server.py
@@ -0,0 +1,369 @@
+#!/usr/bin/env python3
+import logging
+import os
+import asyncio
+import time
+
+from apscheduler.schedulers.asyncio import AsyncIOScheduler
+
+from home.config import config
+from home.util import parse_addr
+from home import http
+from home.database.sqlite import SQLiteBase
+from home.camera import util as camutil
+
+from enum import Enum
+from typing import Optional, Union
+from datetime import datetime, timedelta
+
+
+class TimeFilterType(Enum):
+ FIX = 'fix'
+ MOTION = 'motion'
+
+
+def valid_recording_name(filename: str) -> bool:
+ return filename.startswith('record_') and filename.endswith('.mp4')
+
+
+def filename_to_datetime(filename: str) -> datetime:
+ filename = os.path.basename(filename).replace('record_', '').replace('.mp4', '')
+ return datetime.strptime(filename, datetime_format)
+
+
+# ipcam database
+# --------------
+
+class IPCamServerDatabase(SQLiteBase):
+ SCHEMA = 3
+
+ def __init__(self):
+ super().__init__()
+
+ def schema_init(self, version: int) -> None:
+ cursor = self.cursor()
+
+ if version < 1:
+ # timestamps
+ cursor.execute("""CREATE TABLE IF NOT EXISTS timestamps (
+ camera INTEGER PRIMARY KEY,
+ fix_time INTEGER NOT NULL,
+ motion_time INTEGER NOT NULL
+ )""")
+ for cam in config['camera'].keys():
+ self.add_camera(cam)
+
+ if version < 2:
+ # motion_failures
+ cursor.execute("""CREATE TABLE IF NOT EXISTS motion_failures (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ camera INTEGER NOT NULL,
+ filename TEXT NOT NULL
+ )""")
+
+ if version < 3:
+ cursor.execute("ALTER TABLE motion_failures ADD COLUMN message TEXT NOT NULL DEFAULT ''")
+
+ self.commit()
+
+ def add_camera(self, camera: int):
+ self.cursor().execute("INSERT INTO timestamps (camera, fix_time, motion_time) VALUES (?, ?, ?)",
+ (camera, 0, 0))
+ self.commit()
+
+ def add_motion_failure(self,
+ camera: int,
+ filename: str,
+ message: Optional[str]):
+ self.cursor().execute("INSERT INTO motion_failures (camera, filename, message) VALUES (?, ?, ?)",
+ (camera, filename, message or ''))
+ self.commit()
+
+ def get_all_timestamps(self):
+ cur = self.cursor()
+ data = {}
+
+ cur.execute("SELECT camera, fix_time, motion_time FROM timestamps")
+ for cam, fix_time, motion_time in cur.fetchall():
+ data[int(cam)] = {
+ 'fix': int(fix_time),
+ 'motion': int(motion_time)
+ }
+
+ return data
+
+ def set_timestamp(self,
+ camera: int,
+ time_type: TimeFilterType,
+ time: Union[int, datetime]):
+ cur = self.cursor()
+ if isinstance(time, datetime):
+ time = int(time.timestamp())
+ cur.execute(f"UPDATE timestamps SET {time_type.value}_time=? WHERE camera=?", (time, camera))
+ self.commit()
+
+ def get_timestamp(self,
+ camera: int,
+ time_type: TimeFilterType) -> int:
+ cur = self.cursor()
+ cur.execute(f"SELECT {time_type.value}_time FROM timestamps WHERE camera=?", (camera,))
+ return int(cur.fetchone()[0])
+
+
+# ipcam web api
+# -------------
+
+class IPCamWebServer(http.HTTPServer):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ self.get('/api/recordings/{name}', self.get_camera_recordings)
+ self.get('/api/recordings/{name}/download/{file}', self.download_recording)
+ self.get('/api/camera/list', self.camlist)
+ self.get('/api/timestamp/{name}/{type}', self.get_timestamp)
+ self.get('/api/timestamp/all', self.get_all_timestamps)
+
+ self.post('/api/debug/migrate-mtimes', self.debug_migrate_mtimes)
+ self.post('/api/debug/fix', self.debug_fix)
+ self.post('/api/timestamp/{name}/{type}', self.set_timestamp)
+
+ self.post('/api/motion/done/{name}', self.submit_motion)
+ self.post('/api/motion/fail/{name}', self.submit_motion_failure)
+
+ async def get_camera_recordings(self, req):
+ cam = int(req.match_info['name'])
+ try:
+ filter = TimeFilterType(req.query['filter'])
+ except KeyError:
+ filter = None
+
+ files = get_recordings_files(cam, filter)
+
+ return self.ok({'files': files})
+
+ async def download_recording(self, req: http.Request):
+ cam = int(req.match_info['name'])
+ file = req.match_info['file']
+
+ fullpath = os.path.join(config['camera'][cam]['recordings_path'], file)
+ if not os.path.isfile(fullpath):
+ raise ValueError(f'file "{fullpath}" does not exists')
+
+ return http.FileResponse(fullpath)
+
+ async def camlist(self, req: http.Request):
+ return self.ok(config['camera'])
+
+ async def submit_motion(self, req: http.Request):
+ data = await req.post()
+
+ camera = int(req.match_info['name'])
+ timecodes = data['timecodes']
+ filename = data['filename']
+
+ time = filename_to_datetime(filename)
+
+ try:
+ if timecodes != '':
+ fragments = camutil.dvr_scan_timecodes(timecodes)
+ asyncio.ensure_future(process_fragments(camera, filename, fragments))
+
+ db.set_timestamp(camera, TimeFilterType.MOTION, time)
+ return self.ok()
+
+ except ValueError as e:
+ db.set_timestamp(camera, TimeFilterType.MOTION, time)
+ raise e
+
+ async def submit_motion_failure(self, req: http.Request):
+ camera = int(req.match_info['name'])
+
+ data = await req.post()
+ filename = data['filename']
+ message = data['message']
+
+ db.add_motion_failure(camera, filename, message)
+ db.set_timestamp(camera, TimeFilterType.MOTION, filename_to_datetime(filename))
+
+ return self.ok()
+
+ async def debug_migrate_mtimes(self, req: http.Request):
+ written = {}
+ for cam in config['camera'].keys():
+ confdir = os.path.join(os.getenv('HOME'), '.config', f'video-util-{cam}')
+ for time_type in TimeFilterType:
+ txt_file = os.path.join(confdir, f'{time_type.value}_mtime')
+ if os.path.isfile(txt_file):
+ with open(txt_file, 'r') as fd:
+ data = fd.read()
+ db.set_timestamp(cam, time_type, int(data.strip()))
+
+ if cam not in written:
+ written[cam] = []
+ written[cam].append(time_type)
+
+ return self.ok({'written': written})
+
+ async def debug_fix(self, req: http.Request):
+ asyncio.ensure_future(fix_job())
+ return self.ok()
+
+ async def set_timestamp(self, req: http.Request):
+ cam, time_type, time = self._getset_timestamp_params(req, need_time=True)
+ db.set_timestamp(cam, time_type, time)
+ return self.ok()
+
+ async def get_timestamp(self, req: http.Request):
+ cam, time_type = self._getset_timestamp_params(req)
+ return self.ok(db.get_timestamp(cam, time_type))
+
+ async def get_all_timestamps(self, req: http.Request):
+ return self.ok(db.get_all_timestamps())
+
+ @staticmethod
+ def _getset_timestamp_params(req: http.Request, need_time=False):
+ values = []
+
+ cam = int(req.match_info['name'])
+ assert cam in config['camera'], 'invalid camera'
+
+ values.append(cam)
+ values.append(TimeFilterType(req.match_info['type']))
+
+ if need_time:
+ time = req.query['time']
+ if time.startswith('record_'):
+ time = filename_to_datetime(time)
+ elif time.isnumeric():
+ time = int(time)
+ else:
+ raise ValueError('invalid time')
+ values.append(time)
+
+ return values
+
+
+# other global stuff
+# ------------------
+
+def open_database():
+ global db
+ db = IPCamServerDatabase()
+
+ # update cams list in database, if needed
+ cams = db.get_all_timestamps().keys()
+ for cam in config['camera']:
+ if cam not in cams:
+ db.add_camera(cam)
+
+
+def get_recordings_path(cam: int) -> str:
+ return config['camera'][cam]['recordings_path']
+
+
+def get_motion_path(cam: int) -> str:
+ return config['camera'][cam]['motion_path']
+
+
+def get_recordings_files(cam: int,
+ time_filter_type: Optional[TimeFilterType] = None) -> list[dict]:
+ from_time = 0
+ to_time = int(time.time())
+
+ if time_filter_type:
+ from_time = db.get_timestamp(cam, time_filter_type)
+ if time_filter_type == TimeFilterType.MOTION:
+ to_time = db.get_timestamp(cam, TimeFilterType.FIX)
+
+ from_time = datetime.fromtimestamp(from_time)
+ to_time = datetime.fromtimestamp(to_time)
+
+ recdir = get_recordings_path(cam)
+ files = [{
+ 'name': file,
+ 'size': os.path.getsize(os.path.join(recdir, file))}
+ for file in os.listdir(recdir)
+ if valid_recording_name(file) and from_time < filename_to_datetime(file) <= to_time]
+ files.sort(key=lambda file: file['name'])
+
+ if files:
+ last = files[len(files)-1]
+ fullpath = os.path.join(recdir, last['name'])
+ if camutil.has_handle(fullpath):
+ logger.debug(f'get_recordings_files: file {fullpath} has opened handle, ignoring it')
+ files.pop()
+
+ return files
+
+
+async def process_fragments(camera: int,
+ filename: str,
+ fragments: list[tuple[int, int]]) -> None:
+ time = filename_to_datetime(filename)
+
+ rec_dir = get_recordings_path(camera)
+ motion_dir = get_motion_path(camera)
+ if not os.path.exists(motion_dir):
+ os.mkdir(motion_dir)
+
+ for fragment in fragments:
+ start, end = fragment
+
+ start -= config['motion']['padding']
+ end += config['motion']['padding']
+
+ if start < 0:
+ start = 0
+
+ duration = end - start
+
+ dt1 = (time + timedelta(seconds=start)).strftime(datetime_format)
+ dt2 = (time + timedelta(seconds=end)).strftime(datetime_format)
+
+ await camutil.ffmpeg_cut(input=os.path.join(rec_dir, filename),
+ output=os.path.join(motion_dir, f'{dt1}__{dt2}.mp4'),
+ start_pos=start,
+ duration=duration)
+
+
+async def fix_job() -> None:
+ logger.debug('fix_job: starting')
+
+ for cam in config['camera'].keys():
+ files = get_recordings_files(cam, TimeFilterType.FIX)
+ if not files:
+ logger.debug(f'fix_job: no files for camera {cam}')
+ continue
+
+ logger.debug(f'fix_job: got %d files for camera {cam}' % (len(files),))
+
+ for file in files:
+ fullpath = os.path.join(get_recordings_path(cam), file['name'])
+ await camutil.ffmpeg_recreate(fullpath)
+ timestamp = filename_to_datetime(file['name'])
+ if timestamp:
+ db.set_timestamp(cam, TimeFilterType.FIX, timestamp)
+
+
+datetime_format = '%Y-%m-%d-%H.%M.%S'
+db: Optional[IPCamServerDatabase] = None
+server: Optional[IPCamWebServer] = None
+logger = logging.getLogger(__name__)
+
+
+# start of the program
+# --------------------
+
+if __name__ == '__main__':
+ config.load('ipcam_server')
+
+ open_database()
+
+ loop = asyncio.get_event_loop()
+
+ scheduler = AsyncIOScheduler(event_loop=loop)
+ scheduler.add_job(fix_job, 'interval', seconds=config['fix_interval'])
+ scheduler.start()
+
+ server = IPCamWebServer(parse_addr(config['server']['listen']))
+ server.run()
diff --git a/src/sound_node.py b/src/sound_node.py
index 8ba1b50..a96a098 100755
--- a/src/sound_node.py
+++ b/src/sound_node.py
@@ -2,18 +2,16 @@
import os
from typing import Optional
-from aiohttp import web
-from aiohttp.web_exceptions import (
- HTTPNotFound
-)
+
from home.config import config
-from home.util import parse_addr, stringify, format_tb
+from home.util import parse_addr
from home.sound import (
amixer,
Recorder,
RecordStatus,
RecordStorage
)
+from home import http
"""
@@ -27,41 +25,10 @@ This script implements HTTP API for amixer and arecord.
# ---------------------
recorder: Optional[Recorder]
-routes = web.RouteTableDef()
+routes = http.routes()
storage: Optional[RecordStorage]
-# common http funcs & helpers
-# ---------------------------
-
-@web.middleware
-async def errors_handler_middleware(request, handler):
- try:
- response = await handler(request)
- return response
-
- except HTTPNotFound:
- return web.json_response({'error': 'not found'}, status=404)
-
- except Exception as exc:
- data = {
- 'error': exc.__class__.__name__,
- 'message': exc.message if hasattr(exc, 'message') else str(exc)
- }
- tb = format_tb(exc)
- if tb:
- data['stacktrace'] = tb
-
- return web.json_response(data, status=500)
-
-
-def ok(data=None):
- if data is None:
- data = 1
- response = {'response': data}
- return web.json_response(response, dumps=stringify)
-
-
# recording methods
# -----------------
@@ -73,14 +40,14 @@ async def do_record(request):
raise ValueError(f'invalid duration: max duration is {max}')
record_id = recorder.record(duration)
- return ok({'id': record_id})
+ return http.ok({'id': record_id})
@routes.get('/record/info/{id}/')
async def record_info(request):
record_id = int(request.match_info['id'])
info = recorder.get_info(record_id)
- return ok(info.as_dict())
+ return http.ok(info.as_dict())
@routes.get('/record/forget/{id}/')
@@ -91,7 +58,7 @@ async def record_forget(request):
assert info.status in (RecordStatus.FINISHED, RecordStatus.ERROR), f"can't forget: record status is {info.status}"
recorder.forget(record_id)
- return ok()
+ return http.ok()
@routes.get('/record/download/{id}/')
@@ -101,7 +68,7 @@ async def record_download(request):
info = recorder.get_info(record_id)
assert info.status == RecordStatus.FINISHED, f"record status is {info.status}"
- return web.FileResponse(info.file.path)
+ return http.FileResponse(info.file.path)
@routes.get('/storage/list/')
@@ -112,7 +79,7 @@ async def storage_list(request):
if extended:
files = list(map(lambda file: file.__dict__(), files))
- return ok({
+ return http.ok({
'files': files
})
@@ -125,7 +92,7 @@ async def storage_delete(request):
raise ValueError(f'file {file} not found')
storage.delete(file)
- return ok()
+ return http.ok()
@routes.get('/storage/download/')
@@ -135,7 +102,7 @@ async def storage_download(request):
if not file:
raise ValueError(f'file {file} not found')
- return web.FileResponse(file.path)
+ return http.FileResponse(file.path)
# ALSA mixer methods
@@ -144,7 +111,7 @@ async def storage_download(request):
def _amixer_control_response(control):
info = amixer.get(control)
caps = amixer.get_caps(control)
- return ok({
+ return http.ok({
'caps': caps,
'info': info
})
@@ -153,7 +120,7 @@ def _amixer_control_response(control):
@routes.get('/amixer/get-all/')
async def amixer_get_all(request):
controls_info = amixer.get_all()
- return ok(controls_info)
+ return http.ok(controls_info)
@routes.get('/amixer/get/{control}/')
@@ -213,13 +180,4 @@ if __name__ == '__main__':
recorder = Recorder(storage=storage)
recorder.start_thread()
- # start http server
- host, port = parse_addr(config['node']['listen'])
- app = web.Application()
- app.add_routes(routes)
- app.middlewares.append(errors_handler_middleware)
-
- web.run_app(app,
- host=host,
- port=port,
- handle_signals=True)
+ http.serve(parse_addr(config['node']['listen']), routes)
diff --git a/src/test/test_inverter_monitor.py b/src/test/test_inverter_monitor.py
index d9b63d3..4cccb77 100755
--- a/src/test/test_inverter_monitor.py
+++ b/src/test/test_inverter_monitor.py
@@ -347,8 +347,8 @@ def main():
charger.start()
# init inverterd wrapper
- inverter.init(host=config['inverter']['host'],
- port=config['inverter']['port'])
+ inverter.schema_init(host=config['inverter']['host'],
+ port=config['inverter']['port'])
# start monitor
mon = InverterMonitor()
diff --git a/systemd/ipcam_server.service b/systemd/ipcam_server.service
new file mode 100644
index 0000000..8897363
--- /dev/null
+++ b/systemd/ipcam_server.service
@@ -0,0 +1,13 @@
+[Unit]
+Description=HomeKit IPCam Server
+After=network-online.target
+
+[Service]
+User=user
+Group=user
+Restart=on-failure
+ExecStart=/home/user/homekit/src/ipcam_server.py
+WorkingDirectory=/home/user
+
+[Install]
+WantedBy=multi-user.target \ No newline at end of file
diff --git a/systemd/sound_bot.service b/systemd/sound_bot.service
index 63bde89..51a9e0f 100644
--- a/systemd/sound_bot.service
+++ b/systemd/sound_bot.service
@@ -1,5 +1,5 @@
[Unit]
-Description=MyHomeKit's Sound Bot for Telegram
+Description=HomeKit Sound Bot for Telegram
After=network-online.target
[Service]
diff --git a/systemd/sound_node.service b/systemd/sound_node.service
index 9d47a4f..e3e3afd 100644
--- a/systemd/sound_node.service
+++ b/systemd/sound_node.service
@@ -1,5 +1,5 @@
[Unit]
-Description=MyHomeKit Sound Node (ALSA HTTP Frontend)
+Description=HomeKit Sound Node (ALSA HTTP Frontend)
After=network-online.target
[Service]
diff --git a/systemd/sound_sensor_node.service b/systemd/sound_sensor_node.service
index 595e050..d10f976 100644
--- a/systemd/sound_sensor_node.service
+++ b/systemd/sound_sensor_node.service
@@ -1,5 +1,5 @@
[Unit]
-Description=MyHomeKit Sound Sensor Node
+Description=HomeKit Sound Sensor Node
After=network-online.target
[Service]
diff --git a/systemd/sound_sensor_server.service b/systemd/sound_sensor_server.service
index f45abed..0133e53 100644
--- a/systemd/sound_sensor_server.service
+++ b/systemd/sound_sensor_server.service
@@ -1,5 +1,5 @@
[Unit]
-Description=MyHomeKit Sound Sensor Central Server
+Description=HomeKit Sound Sensor Central Server
After=network-online.target
[Service]
diff --git a/tools/ipcam_motion_worker.sh b/tools/ipcam_motion_worker.sh
new file mode 100755
index 0000000..52ca487
--- /dev/null
+++ b/tools/ipcam_motion_worker.sh
@@ -0,0 +1,236 @@
+#!/bin/bash
+
+set -e
+
+DIR="$( cd "$( dirname "$(realpath "${BASH_SOURCE[0]}")" )" &>/dev/null && pwd )"
+PROGNAME="$0"
+
+. "$DIR/lib.bash"
+
+allow_multiple=
+config_file="$HOME/.config/ipcam_motion_worker/config.txt"
+declare -A config=()
+
+usage() {
+ cat <<EOF
+usage: $PROGNAME OPTIONS
+
+Options:
+ -c|--config FILE configuration file, default is $config_file
+ -v, -vx be verbose.
+ -v enables debug logs.
+ -vx does \`set -x\`, may be used to debug the script.
+ --allow-multiple don't check for another instance
+EOF
+ exit 1
+}
+
+get_recordings_dir() {
+ curl -s "${config[api_url]}/api/camera/list" \
+ | jq ".response.\"${config[camera]}\".recordings_path" | tr -d '"'
+}
+
+# returns two words per line:
+# filename filesize
+get_recordings_list() {
+ curl -s "${config[api_url]}/api/recordings/${config[camera]}?filter=motion" \
+ | jq '.response.files[] | [.name, .size] | join(" ")' | tr -d '"'
+}
+
+report_failure() {
+ local file="$1"
+ local message="$2"
+ local response=$(curl -s -X POST "${config[api_url]}/api/motion/fail/${config[camera]}" \
+ -F "filename=$file" \
+ -F "message=$message")
+ print_response_error "$response" "report_failure"
+}
+
+report_timecodes() {
+ local file="$1"
+ local timecodes="$2"
+ local response=$(curl -s -X POST "${config[api_url]}/api/motion/done/${config[camera]}" \
+ -F "filename=$file" \
+ -F "timecodes=$timecodes")
+ print_response_error "$response" "report_timecodes"
+}
+
+print_response_error() {
+ local resp="$1"
+ local sufx="$2"
+
+ local error="$(echo "$resp" | jq '.error')"
+ local message
+
+ if [ "$error" != "null" ]; then
+ message="$(echo "$resp" | jq '.message' | tr -d '"')"
+ error="$(echo "$error" | tr -d '"')"
+
+ echoerr "$sufx: $error ($message)"
+ fi
+}
+
+get_roi_file() {
+ if [ -n "${config[roi_file]}" ]; then
+ file="${config[roi_file]}"
+ if ! [[ "$file" =~ ^/.* ]]; then
+ file="$(dirname "$config_file")/$file"
+ fi
+
+ debug "get_roi_file: detected file $file"
+ [ -f "$file" ] || die "invalid roi_file: $file: no such file"
+
+ echo "$file"
+ fi
+}
+
+process_local() {
+ local recdir="$(get_recordings_dir)"
+ local tc
+ local words
+ local file
+
+ while read line; do
+ words=($line)
+ file=${words[0]}
+
+ debug "processing $file..."
+
+ tc=$(do_motion "${recdir}/$file")
+ debug "$file: timecodes=$tc"
+
+ report_timecodes "$file" "$tc"
+ done < <(get_recordings_list)
+}
+
+process_remote() {
+ local tc
+ local url
+ local words
+ local file
+ local size
+
+ pushd "${config[fs_root]}" >/dev/null || die "failed to change to ${config[fs_root]}"
+ touch tmp || die "directory '${config[fs_root]}' is not writable"
+ rm tmp
+
+ [ -f "video.mp4" ] && {
+ echowarn "video.mp4 already exists in ${config[fs_root]}, removing.."
+ rm "video.mp4"
+ }
+
+ while read line; do
+ words=($line)
+ file=${words[0]}
+ size=${words[1]}
+
+ if (( size > config[fs_max_filesize] )); then
+ echoerr "won't download $file, size exceedes fs_max_filesize ($size > ${config[fs_max_filesize]})"
+ report_failure "$file" "too large file"
+ continue
+ fi
+
+ url="${config[api_url]}/api/recordings/${config[camera]}/download/${file}"
+ debug "downloading $url..."
+
+ if ! download "$url" "video.mp4"; then
+ echoerr "failed to download $file"
+ report_failure "$file" "download error"
+ continue
+ fi
+
+ tc=$(do_motion "video.mp4")
+ debug "$file: timecodes=$tc"
+
+ report_timecodes "$file" "$tc"
+
+ rm "video.mp4"
+ done < <(get_recordings_list)
+
+ popd >/dev/null
+}
+
+do_motion() {
+ local input="$1"
+ local roi_file="$(get_roi_file)"
+
+ local timecodes=()
+ if [ -z "$roi_file" ]; then
+ timecodes+=($(dvr_scan "$input"))
+ else
+ echoinfo "using roi sets from file: ${BOLD}$roi_file"
+ while read line; do
+ if ! [[ "$line" =~ ^#.* ]]; then
+ timecodes+=("$(dvr_scan "$input" "$line")")
+ fi
+ done < <(cat "$roi_file")
+ fi
+
+ timecodes="${timecodes[@]}"
+ timecodes=${timecodes// /,}
+
+ echo "$timecodes"
+}
+
+dvr_scan() {
+ local input="$1"
+ local args=
+ if [ ! -z "$2" ]; then
+ args="-roi $2"
+ echoinfo "dvr_scan(${BOLD}${input}${RST}${CYAN}): roi=($2), mt=${config[threshold]}"
+ else
+ echoinfo "dvr_scan(${BOLD}${input}${RST}${CYAN}): no roi, mt=${config[threshold]}"
+ fi
+ time_start
+ dvr-scan -q -i "$input" -so --min-event-length 3s -df 3 --frame-skip 2 -t ${config[threshold]} $args | tail -1
+ debug "dvr_scan: finished in $(time_elapsed)s"
+}
+
+[[ $# -lt 1 ]] && usage
+
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ -c|--config)
+ config_file="$2"
+ shift; shift
+ ;;
+
+ --allow-multiple)
+ allow_multiple=1
+ shift
+ ;;
+
+ -v)
+ VERBOSE=1
+ shift
+ ;;
+
+ -vx)
+ VERBOSE=1
+ set -x
+ shift
+ ;;
+
+ *)
+ die "unrecognized argument '$1'"
+ exit 1
+ ;;
+ esac
+done
+
+if [ -z "$allow_multiple" ] && pidof -o %PPID -x "$(basename "${BASH_SOURCE[0]}")" >/dev/null; then
+ die "process already running"
+fi
+
+read_config "$config_file" config
+check_config config "api_url camera threshold"
+
+if [ -n "${config[remote]}" ]; then
+ check_config config "fs_root fs_max_filesize"
+fi
+
+if [ -z "${config[remote]}" ]; then
+ process_local
+else
+ process_remote
+fi
diff --git a/tools/ipcam_motion_worker_multiple.sh b/tools/ipcam_motion_worker_multiple.sh
new file mode 100755
index 0000000..5da6974
--- /dev/null
+++ b/tools/ipcam_motion_worker_multiple.sh
@@ -0,0 +1,49 @@
+#!/bin/bash
+
+set -e
+
+DIR="$( cd "$( dirname "$(realpath "${BASH_SOURCE[0]}")" )" &>/dev/null && pwd )"
+PROGNAME="$0"
+
+. "$DIR/lib.bash"
+
+configs=()
+
+usage() {
+ cat <<EOF
+usage: $PROGNAME [OPTIONS] CONFIG_NAME ...
+
+Options:
+ -v be verbose
+EOF
+ exit 1
+}
+
+[[ $# -lt 1 ]] && usage
+
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ -v)
+ VERBOSE=1
+ shift
+ ;;
+
+ *)
+ configs+=("$1")
+ shift
+ ;;
+ esac
+done
+
+[ -z "$configs" ] && die "no config files supplied"
+
+if pidof -o %PPID -x "$(basename "${BASH_SOURCE[0]}")" >/dev/null; then
+ die "process already running"
+fi
+
+worker_args=
+[ "$VERBOSE" = "1" ] && worker_args="-v"
+for name in "${configs[@]}"; do
+ echoinfo "starting worker $name..."
+ $DIR/ipcam_motion_worker.sh $worker_args -c "$HOME/.config/ipcam_motion_worker/$name.txt" --allow-multiple
+done
diff --git a/tools/lib.bash b/tools/lib.bash
new file mode 100644
index 0000000..1cf459b
--- /dev/null
+++ b/tools/lib.bash
@@ -0,0 +1,122 @@
+# colored output
+# --------------
+
+BOLD=$(tput bold)
+RST=$(tput sgr0)
+RED=$(tput setaf 1)
+GREEN=$(tput setaf 2)
+YELLOW=$(tput setaf 3)
+CYAN=$(tput setaf 6)
+VERBOSE=
+
+echoinfo() {
+ >&2 echo "${CYAN}$@${RST}"
+}
+
+echoerr() {
+ >&2 echo "${RED}${BOLD}error:${RST}${RED} $@${RST}"
+}
+
+echowarn() {
+ >&2 echo "${YELLOW}${BOLD}warning:${RST}${YELLOW} $@${RST}"
+}
+
+die() {
+ echoerr "$@"
+ exit 1
+}
+
+debug() {
+ if [ -n "$VERBOSE" ]; then
+ >&2 echo "$@"
+ fi
+}
+
+
+# measuring executing time
+# ------------------------
+
+__time_started=
+
+time_start() {
+ __time_started=$(date +%s)
+}
+
+time_elapsed() {
+ local fin=$(date +%s)
+ echo $(( fin - __time_started ))
+}
+
+
+# config parsing
+# --------------
+
+read_config() {
+ local config_file="$1"
+ local dst="$2"
+
+ [ -f "$config_file" ] || die "read_config: $config_file: no such file"
+
+ local n=0
+ local failed=
+ local key
+ local value
+
+ while read line; do
+ n=$(( n+1 ))
+
+ # skip empty lines or comments
+ if [ -z "$line" ] || [[ "$line" =~ ^#.* ]]; then
+ continue
+ fi
+
+ if [[ $line = *"="* ]]; then
+ key="${line%%=*}"
+ value="${line#*=}"
+ eval "$dst[$key]=\"$value\""
+ else
+ echoerr "config: invalid line $n"
+ failed=1
+ fi
+ done < <(cat "$config_file")
+
+ [ -z "$failed" ]
+}
+
+check_config() {
+ local var="$1"
+ local keys="$2"
+
+ local failed=
+
+ for key in $keys; do
+ if [ -z "$(eval "echo -n \${$var[$key]}")" ]; then
+ echoerr "config: ${BOLD}${key}${RST}${RED} is missing"
+ failed=1
+ fi
+ done
+
+ [ -z "$failed" ]
+}
+
+
+# other functions
+# ---------------
+
+installed() {
+ command -v "$1" > /dev/null
+ return $?
+}
+
+download() {
+ local source="$1"
+ local target="$2"
+
+ if installed curl; then
+ curl -f -s -o "$target" "$source"
+ elif installed wget; then
+ wget -q -O "$target" "$source"
+ else
+ die "neither curl nor wget found, can't proceed"
+ fi
+}
diff --git a/tools/process-motion-timecodes.py b/tools/process-motion-timecodes.py
index ba4ee26..7be7977 100755
--- a/tools/process-motion-timecodes.py
+++ b/tools/process-motion-timecodes.py
@@ -1,5 +1,6 @@
#!/usr/bin/env python3
import os.path
+from src.home.camera.util import dvr_scan_timecodes
from argparse import ArgumentParser
from datetime import datetime, timedelta
@@ -39,31 +40,10 @@ if __name__ == '__main__':
if arg.padding < 0:
raise ValueError('invalid padding')
- timecodes = arg.timecodes.split(',')
- if len(timecodes) % 2 != 0:
- raise ValueError('invalid number of timecodes')
-
- timecodes = list(map(time2seconds, timecodes))
- timecodes = list(chunks(timecodes, 2))
-
- # sort out invalid fragments (dvr-scan returns them sometimes, idk why...)
- timecodes = list(filter(lambda f: f[0] < f[1], timecodes))
- if not timecodes:
- raise ValueError('no valid timecodes')
-
+ fragments = dvr_scan_timecodes(arg.timecodes)
file_dt = filename_to_datetime(arg.source_filename)
- # https://stackoverflow.com/a/43600953
- timecodes.sort(key=lambda interval: interval[0])
- merged = [timecodes[0]]
- for current in timecodes:
- previous = merged[-1]
- if current[0] <= previous[1]:
- previous[1] = max(previous[1], current[1])
- else:
- merged.append(current)
-
- for fragment in merged:
+ for fragment in fragments:
start, end = fragment
start -= arg.padding
diff --git a/tools/video-util.sh b/tools/video-util.sh
index 08d8938..0a148d8 100755
--- a/tools/video-util.sh
+++ b/tools/video-util.sh
@@ -5,12 +5,7 @@ set -e
DIR="$( cd "$( dirname "$(realpath "${BASH_SOURCE[0]}")" )" &> /dev/null && pwd )"
PROGNAME="$0"
-BOLD=$(tput bold)
-RST=$(tput sgr0)
-RED=$(tput setaf 1)
-GREEN=$(tput setaf 2)
-YELLOW=$(tput setaf 3)
-CYAN=$(tput setaf 6)
+. "$DIR/lib.bash"
input=
output=
@@ -18,46 +13,11 @@ command=
motion_threshold=1
ffmpeg_args="-nostats -loglevel error"
dvr_scan_args="-q"
-verbose=
config_dir=$HOME/.config/video-util
config_dir_set=
write_data_prefix=
write_data_time=
-_time_started=
-
-time_start() {
- _time_started=$(date +%s)
-}
-
-time_elapsed() {
- local _time_finished=$(date +%s)
- echo $(( _time_finished - _time_started ))
-}
-
-debug() {
- if [ -n "$verbose" ]; then
- >&2 echo "$@"
- fi
-}
-
-echoinfo() {
- >&2 echo "${CYAN}$@${RST}"
-}
-
-echoerr() {
- >&2 echo "${RED}${BOLD}error:${RST}${RED} $@${RST}"
-}
-
-echowarn() {
- >&2 echo "${YELLOW}${BOLD}warning:${RST}${YELLOW} $@${RST}"
-}
-
-die() {
- echoerr "$@"
- exit 1
-}
-
file_in_use() {
[ -n "$(lsof "$1")" ]
}
@@ -223,44 +183,6 @@ do_mass_fix_mtime() {
done
}
-do_motion() {
- local input="$1"
- local timecodes=()
- local roi_file="$config_dir/roi.txt"
- if ! [ -f "$roi_file" ]; then
- timecodes+=($(dvr_scan "$input"))
- else
- echoinfo "using roi sets from file: ${BOLD}$roi_file"
- while read line; do
- if ! [[ "$line" =~ ^#.* ]]; then
- timecodes+=("$(dvr_scan "$input" "$line")")
- fi
- done < <(cat "$roi_file")
- fi
-
- timecodes="${timecodes[@]}"
- timecodes=${timecodes// /,}
-
- if [ -z "$timecodes" ]; then
- debug "do_motion: no motion detected"
- else
- debug "do_motion: detected timecodes: $timecodes"
-
- local output_dir="$(dirname "$input")/motion"
- if ! [ -d "$output_dir" ]; then
- mkdir "$output_dir" || die "do_motion: mkdir($output_dir) failed"
- debug "do_motion: created $output_dir directory"
- fi
-
- local fragment
- while read line; do
- fragment=($line)
- debug "do_motion: writing fragment start=${fragment[0]} duration=${fragment[1]} filename=$output_dir/${fragment[2]}"
- ffmpeg $ffmpeg_args -i "$input" -ss ${fragment[0]} -t ${fragment[1]} -c copy -y "$output_dir/${fragment[2]}" </dev/null
- done < <($DIR/process-motion-timecodes.py --source-filename "$input" --timecodes "$timecodes")
- fi
-}
-
do_mass_motion() {
local input="$1"
local saved_time=$(config_get_prev_mtime motion)
@@ -285,20 +207,6 @@ do_mass_motion() {
# echo "00:05:06.930,00:05:24.063"
#}
-dvr_scan() {
- local input="$1"
- local args=
- if [ ! -z "$2" ]; then
- args="-roi $2"
- echoinfo "dvr_scan(${BOLD}${input}${RST}${CYAN}): roi=($2), mt=$motion_threshold"
- else
- echoinfo "dvr_scan(${BOLD}${input}${RST}${CYAN}): no roi, mt=$motion_threshold"
- fi
- time_start
- dvr-scan $dvr_scan_args -i "$input" -so --min-event-length 3s -df 3 --frame-skip 2 -t $motion_threshold $args | tail -1
- debug "dvr_scan: finished in $(time_elapsed)s"
-}
-
[[ $# -lt 1 ]] && usage
while [[ $# -gt 0 ]]; do