ipcam: rewrite motion detection system

This commit is contained in:
Evgeny Zinoviev 2022-06-08 22:01:22 +03:00
parent 1ed87f6987
commit eb502ab9c9
26 changed files with 1198 additions and 284 deletions

4
.gitignore vendored
View File

@ -6,8 +6,10 @@ __pycache__
/src/test/test_inverter_monitor.log /src/test/test_inverter_monitor.log
/esp32-cam/CameraWebServer/wifi_password.h /esp32-cam/CameraWebServer/wifi_password.h
*.swp
/localwebsite/vendor /localwebsite/vendor
/localwebsite/.debug.log /localwebsite/.debug.log
/localwebsite/config.local.php /localwebsite/config.local.php
/localwebsite/cache /localwebsite/cache
/localwebsite/test.php /localwebsite/test.php

View File

@ -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

24
doc/ipcam_server.md Normal file
View File

@ -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
```

View File

@ -7,11 +7,12 @@ mysql-connector-python~=8.0.27
Werkzeug~=2.0.2 Werkzeug~=2.0.2
uwsgi~=2.0.20 uwsgi~=2.0.20
python-telegram-bot~=13.1 python-telegram-bot~=13.1
inverterd~=1.0.2
requests~=2.26.0 requests~=2.26.0
aiohttp~=3.8.1 aiohttp~=3.8.1
pytz~=2021.3 pytz~=2021.3
PyYAML~=6.0 PyYAML~=6.0
apscheduler~=3.9.1
psutil~=5.9.1
# following can be installed from debian repositories # following can be installed from debian repositories
# matplotlib~=3.5.0 # matplotlib~=3.5.0

View File

@ -1,80 +1,32 @@
import sqlite3 from ..database.sqlite import SQLiteBase
import os.path
import logging
from ..config import config
logger = logging.getLogger(__name__)
def _get_database_path() -> str: class Store(SQLiteBase):
return os.path.join(os.environ['HOME'], '.config', config.app_name, 'bot.db')
class Store:
SCHEMA_VERSION = 1
def __init__(self): def __init__(self):
self.sqlite = sqlite3.connect(_get_database_path(), check_same_thread=False) super().__init__()
sqlite_version = self._get_sqlite_version() def schema_init(self, version: int) -> None:
logger.info(f'SQLite version: {sqlite_version}') if version < 1:
cursor = self.cursor()
schema_version = self._get_schema_version() cursor.execute("""CREATE TABLE IF NOT EXISTS users (
logger.info(f'Schema version: {schema_version}') id INTEGER PRIMARY KEY,
lang TEXT NOT NULL
if schema_version < 1: )""")
self._database_init() self.commit()
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
# self.sqlite.commit()
self._set_schema_version(version)
def get_user_lang(self, user_id: int, default: str = 'en') -> str: 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,)) cursor.execute('SELECT lang FROM users WHERE id=?', (user_id,))
row = cursor.fetchone() row = cursor.fetchone()
if row is None: if row is None:
cursor.execute('INSERT INTO users (id, lang) VALUES (?, ?)', (user_id, default)) cursor.execute('INSERT INTO users (id, lang) VALUES (?, ?)', (user_id, default))
self.sqlite.commit() self.commit()
return default return default
else: else:
return row[0] return row[0]
def set_user_lang(self, user_id: int, lang: str) -> None: 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)) cursor.execute('UPDATE users SET lang=? WHERE id=?', (lang, user_id))
self.sqlite.commit() self.commit()

100
src/home/camera/util.py Normal file
View File

@ -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

View File

@ -120,6 +120,7 @@ def setup_logging(verbose=False, log_file=None, default_fmt=False):
logging_level = logging.INFO logging_level = logging.INFO
if is_development_mode() or verbose: if is_development_mode() or verbose:
logging_level = logging.DEBUG logging_level = logging.DEBUG
_add_logging_level('TRACE', logging.DEBUG-5)
log_config = {'level': logging_level} log_config = {'level': logging_level}
if not default_fmt: if not default_fmt:
@ -130,3 +131,54 @@ def setup_logging(verbose=False, log_file=None, default_fmt=False):
log_config['encoding'] = 'utf-8' log_config['encoding'] = 'utf-8'
logging.basicConfig(**log_config) 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)

View File

@ -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')

View File

@ -0,0 +1,2 @@
from .http import serve, ok, routes, HTTPServer
from aiohttp.web import FileResponse, Request

92
src/home/http/http.py Normal file
View File

@ -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)

View File

@ -4,13 +4,10 @@ import logging
import threading import threading
from ..config import config from ..config import config
from aiohttp import web from .. import http
from aiohttp.web_exceptions import (
HTTPNotFound
)
from typing import Type from typing import Type
from ..util import Addr, stringify, format_tb from ..util import Addr
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -74,52 +71,22 @@ class SoundSensorServer:
loop.run_forever() loop.run_forever()
def run_guard_server(self): def run_guard_server(self):
routes = web.RouteTableDef() routes = http.routes()
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.post('/guard/enable') @routes.post('/guard/enable')
async def guard_enable(request): async def guard_enable(request):
self.set_recording(True) self.set_recording(True)
return ok() return http.ok()
@routes.post('/guard/disable') @routes.post('/guard/disable')
async def guard_disable(request): async def guard_disable(request):
self.set_recording(False) self.set_recording(False)
return ok() return http.ok()
@routes.get('/guard/status') @routes.get('/guard/status')
async def guard_status(request): 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 asyncio.set_event_loop(asyncio.new_event_loop()) # need to create new event loop in new thread
app = web.Application() http.serve(self.addr, routes, handle_signals=False) # handle_signals=True doesn't work in separate thread
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

View File

@ -8,6 +8,7 @@ import logging
import string import string
import random import random
from enum import Enum
from .config import config from .config import config
from datetime import datetime from datetime import datetime
from typing import Tuple, Optional from typing import Tuple, Optional
@ -28,6 +29,8 @@ def json_serial(obj):
"""JSON serializer for datetime objects""" """JSON serializer for datetime objects"""
if isinstance(obj, datetime): if isinstance(obj, datetime):
return obj.timestamp() return obj.timestamp()
if isinstance(obj, Enum):
return obj.value
raise TypeError("Type %s not serializable" % type(obj)) raise TypeError("Type %s not serializable" % type(obj))

View File

@ -452,7 +452,7 @@ class InverterBot(Wrapper):
if __name__ == '__main__': if __name__ == '__main__':
config.load('inverter_bot') 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 = InverterMonitor()
monitor.set_charging_event_handler(monitor_charging) monitor.set_charging_event_handler(monitor_charging)

369
src/ipcam_server.py Executable file
View File

@ -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()

View File

@ -2,18 +2,16 @@
import os import os
from typing import Optional from typing import Optional
from aiohttp import web
from aiohttp.web_exceptions import (
HTTPNotFound
)
from home.config import config from home.config import config
from home.util import parse_addr, stringify, format_tb from home.util import parse_addr
from home.sound import ( from home.sound import (
amixer, amixer,
Recorder, Recorder,
RecordStatus, RecordStatus,
RecordStorage RecordStorage
) )
from home import http
""" """
@ -27,41 +25,10 @@ This script implements HTTP API for amixer and arecord.
# --------------------- # ---------------------
recorder: Optional[Recorder] recorder: Optional[Recorder]
routes = web.RouteTableDef() routes = http.routes()
storage: Optional[RecordStorage] 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 # recording methods
# ----------------- # -----------------
@ -73,14 +40,14 @@ async def do_record(request):
raise ValueError(f'invalid duration: max duration is {max}') raise ValueError(f'invalid duration: max duration is {max}')
record_id = recorder.record(duration) record_id = recorder.record(duration)
return ok({'id': record_id}) return http.ok({'id': record_id})
@routes.get('/record/info/{id}/') @routes.get('/record/info/{id}/')
async def record_info(request): async def record_info(request):
record_id = int(request.match_info['id']) record_id = int(request.match_info['id'])
info = recorder.get_info(record_id) info = recorder.get_info(record_id)
return ok(info.as_dict()) return http.ok(info.as_dict())
@routes.get('/record/forget/{id}/') @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}" assert info.status in (RecordStatus.FINISHED, RecordStatus.ERROR), f"can't forget: record status is {info.status}"
recorder.forget(record_id) recorder.forget(record_id)
return ok() return http.ok()
@routes.get('/record/download/{id}/') @routes.get('/record/download/{id}/')
@ -101,7 +68,7 @@ async def record_download(request):
info = recorder.get_info(record_id) info = recorder.get_info(record_id)
assert info.status == RecordStatus.FINISHED, f"record status is {info.status}" 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/') @routes.get('/storage/list/')
@ -112,7 +79,7 @@ async def storage_list(request):
if extended: if extended:
files = list(map(lambda file: file.__dict__(), files)) files = list(map(lambda file: file.__dict__(), files))
return ok({ return http.ok({
'files': files 'files': files
}) })
@ -125,7 +92,7 @@ async def storage_delete(request):
raise ValueError(f'file {file} not found') raise ValueError(f'file {file} not found')
storage.delete(file) storage.delete(file)
return ok() return http.ok()
@routes.get('/storage/download/') @routes.get('/storage/download/')
@ -135,7 +102,7 @@ async def storage_download(request):
if not file: if not file:
raise ValueError(f'file {file} not found') raise ValueError(f'file {file} not found')
return web.FileResponse(file.path) return http.FileResponse(file.path)
# ALSA mixer methods # ALSA mixer methods
@ -144,7 +111,7 @@ async def storage_download(request):
def _amixer_control_response(control): def _amixer_control_response(control):
info = amixer.get(control) info = amixer.get(control)
caps = amixer.get_caps(control) caps = amixer.get_caps(control)
return ok({ return http.ok({
'caps': caps, 'caps': caps,
'info': info 'info': info
}) })
@ -153,7 +120,7 @@ def _amixer_control_response(control):
@routes.get('/amixer/get-all/') @routes.get('/amixer/get-all/')
async def amixer_get_all(request): async def amixer_get_all(request):
controls_info = amixer.get_all() controls_info = amixer.get_all()
return ok(controls_info) return http.ok(controls_info)
@routes.get('/amixer/get/{control}/') @routes.get('/amixer/get/{control}/')
@ -213,13 +180,4 @@ if __name__ == '__main__':
recorder = Recorder(storage=storage) recorder = Recorder(storage=storage)
recorder.start_thread() recorder.start_thread()
# start http server http.serve(parse_addr(config['node']['listen']), routes)
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)

View File

@ -347,8 +347,8 @@ def main():
charger.start() charger.start()
# init inverterd wrapper # init inverterd wrapper
inverter.init(host=config['inverter']['host'], inverter.schema_init(host=config['inverter']['host'],
port=config['inverter']['port']) port=config['inverter']['port'])
# start monitor # start monitor
mon = InverterMonitor() mon = InverterMonitor()

View File

@ -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

View File

@ -1,5 +1,5 @@
[Unit] [Unit]
Description=MyHomeKit's Sound Bot for Telegram Description=HomeKit Sound Bot for Telegram
After=network-online.target After=network-online.target
[Service] [Service]

View File

@ -1,5 +1,5 @@
[Unit] [Unit]
Description=MyHomeKit Sound Node (ALSA HTTP Frontend) Description=HomeKit Sound Node (ALSA HTTP Frontend)
After=network-online.target After=network-online.target
[Service] [Service]

View File

@ -1,5 +1,5 @@
[Unit] [Unit]
Description=MyHomeKit Sound Sensor Node Description=HomeKit Sound Sensor Node
After=network-online.target After=network-online.target
[Service] [Service]

View File

@ -1,5 +1,5 @@
[Unit] [Unit]
Description=MyHomeKit Sound Sensor Central Server Description=HomeKit Sound Sensor Central Server
After=network-online.target After=network-online.target
[Service] [Service]

236
tools/ipcam_motion_worker.sh Executable file
View File

@ -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

View File

@ -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

122
tools/lib.bash Normal file
View File

@ -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
}

View File

@ -1,5 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import os.path import os.path
from src.home.camera.util import dvr_scan_timecodes
from argparse import ArgumentParser from argparse import ArgumentParser
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -39,31 +40,10 @@ if __name__ == '__main__':
if arg.padding < 0: if arg.padding < 0:
raise ValueError('invalid padding') raise ValueError('invalid padding')
timecodes = arg.timecodes.split(',') fragments = dvr_scan_timecodes(arg.timecodes)
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')
file_dt = filename_to_datetime(arg.source_filename) file_dt = filename_to_datetime(arg.source_filename)
# https://stackoverflow.com/a/43600953 for fragment in fragments:
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:
start, end = fragment start, end = fragment
start -= arg.padding start -= arg.padding

View File

@ -5,12 +5,7 @@ set -e
DIR="$( cd "$( dirname "$(realpath "${BASH_SOURCE[0]}")" )" &> /dev/null && pwd )" DIR="$( cd "$( dirname "$(realpath "${BASH_SOURCE[0]}")" )" &> /dev/null && pwd )"
PROGNAME="$0" PROGNAME="$0"
BOLD=$(tput bold) . "$DIR/lib.bash"
RST=$(tput sgr0)
RED=$(tput setaf 1)
GREEN=$(tput setaf 2)
YELLOW=$(tput setaf 3)
CYAN=$(tput setaf 6)
input= input=
output= output=
@ -18,46 +13,11 @@ command=
motion_threshold=1 motion_threshold=1
ffmpeg_args="-nostats -loglevel error" ffmpeg_args="-nostats -loglevel error"
dvr_scan_args="-q" dvr_scan_args="-q"
verbose=
config_dir=$HOME/.config/video-util config_dir=$HOME/.config/video-util
config_dir_set= config_dir_set=
write_data_prefix= write_data_prefix=
write_data_time= 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() { file_in_use() {
[ -n "$(lsof "$1")" ] [ -n "$(lsof "$1")" ]
} }
@ -223,44 +183,6 @@ do_mass_fix_mtime() {
done 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() { do_mass_motion() {
local input="$1" local input="$1"
local saved_time=$(config_get_prev_mtime motion) local saved_time=$(config_get_prev_mtime motion)
@ -285,20 +207,6 @@ do_mass_motion() {
# echo "00:05:06.930,00:05:24.063" # 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 [[ $# -lt 1 ]] && usage
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do