diff options
author | Evgeny Zinoviev <me@ch1p.io> | 2022-06-08 22:01:22 +0300 |
---|---|---|
committer | Evgeny Zinoviev <me@ch1p.io> | 2022-06-11 19:05:51 +0300 |
commit | eb502ab9c94cc8a89a29f9310e2f56404b432053 (patch) | |
tree | 70ec81d40a9fc406960d85773436d3b33a014c7a | |
parent | 1ed87f69878b85daf94cde4c7b187939d9e15778 (diff) |
ipcam: rewrite motion detection system
-rw-r--r-- | .gitignore | 4 | ||||
-rw-r--r-- | doc/ipcam_motion_worker.md | 22 | ||||
-rw-r--r-- | doc/ipcam_server.md | 24 | ||||
-rw-r--r-- | requirements.txt | 3 | ||||
-rw-r--r-- | src/home/bot/store.py | 78 | ||||
-rw-r--r-- | src/home/camera/util.py | 100 | ||||
-rw-r--r-- | src/home/config/config.py | 52 | ||||
-rw-r--r-- | src/home/database/sqlite.py | 62 | ||||
-rw-r--r-- | src/home/http/__init__.py | 2 | ||||
-rw-r--r-- | src/home/http/http.py | 92 | ||||
-rw-r--r-- | src/home/soundsensor/server.py | 49 | ||||
-rw-r--r-- | src/home/util.py | 3 | ||||
-rwxr-xr-x | src/inverter_bot.py | 2 | ||||
-rwxr-xr-x | src/ipcam_server.py | 369 | ||||
-rwxr-xr-x | src/sound_node.py | 70 | ||||
-rwxr-xr-x | src/test/test_inverter_monitor.py | 4 | ||||
-rw-r--r-- | systemd/ipcam_server.service | 13 | ||||
-rw-r--r-- | systemd/sound_bot.service | 2 | ||||
-rw-r--r-- | systemd/sound_node.service | 2 | ||||
-rw-r--r-- | systemd/sound_sensor_node.service | 2 | ||||
-rw-r--r-- | systemd/sound_sensor_server.service | 2 | ||||
-rwxr-xr-x | tools/ipcam_motion_worker.sh | 236 | ||||
-rwxr-xr-x | tools/ipcam_motion_worker_multiple.sh | 49 | ||||
-rw-r--r-- | tools/lib.bash | 122 | ||||
-rwxr-xr-x | tools/process-motion-timecodes.py | 26 | ||||
-rwxr-xr-x | tools/video-util.sh | 94 |
26 files changed, 1199 insertions, 285 deletions
@@ -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 |