ipcam: rewrite motion detection system
This commit is contained in:
parent
1ed87f6987
commit
eb502ab9c9
2
.gitignore
vendored
2
.gitignore
vendored
@ -6,6 +6,8 @@ __pycache__
|
||||
/src/test/test_inverter_monitor.log
|
||||
/esp32-cam/CameraWebServer/wifi_password.h
|
||||
|
||||
*.swp
|
||||
|
||||
/localwebsite/vendor
|
||||
/localwebsite/.debug.log
|
||||
/localwebsite/config.local.php
|
||||
|
22
doc/ipcam_motion_worker.md
Normal file
22
doc/ipcam_motion_worker.md
Normal 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
24
doc/ipcam_server.md
Normal 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
|
||||
|
||||
```
|
@ -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
|
||||
|
@ -1,80 +1,32 @@
|
||||
import sqlite3
|
||||
import os.path
|
||||
import logging
|
||||
|
||||
from ..config import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from ..database.sqlite import SQLiteBase
|
||||
|
||||
|
||||
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)
|
||||
super().__init__()
|
||||
|
||||
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
|
||||
|
||||
# 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()
|
||||
self.commit()
|
||||
|
100
src/home/camera/util.py
Normal file
100
src/home/camera/util.py
Normal 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
|
@ -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)
|
62
src/home/database/sqlite.py
Normal file
62
src/home/database/sqlite.py
Normal 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')
|
2
src/home/http/__init__.py
Normal file
2
src/home/http/__init__.py
Normal 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
92
src/home/http/http.py
Normal 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)
|
@ -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)
|
||||
http.serve(self.addr, routes, handle_signals=False) # handle_signals=True doesn't work in separate thread
|
||||
|
||||
web.run_app(app,
|
||||
host=self.addr[0],
|
||||
port=self.addr[1],
|
||||
handle_signals=False) # handle_signals=True doesn't work in separate thread
|
||||
|
@ -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))
|
||||
|
||||
|
||||
|
@ -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)
|
||||
|
369
src/ipcam_server.py
Executable file
369
src/ipcam_server.py
Executable 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()
|
@ -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)
|
||||
|
@ -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()
|
||||
|
13
systemd/ipcam_server.service
Normal file
13
systemd/ipcam_server.service
Normal 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
|
@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=MyHomeKit's Sound Bot for Telegram
|
||||
Description=HomeKit Sound Bot for Telegram
|
||||
After=network-online.target
|
||||
|
||||
[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]
|
||||
|
@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=MyHomeKit Sound Sensor Node
|
||||
Description=HomeKit Sound Sensor Node
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
|
@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=MyHomeKit Sound Sensor Central Server
|
||||
Description=HomeKit Sound Sensor Central Server
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
|
236
tools/ipcam_motion_worker.sh
Executable file
236
tools/ipcam_motion_worker.sh
Executable 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
|
49
tools/ipcam_motion_worker_multiple.sh
Executable file
49
tools/ipcam_motion_worker_multiple.sh
Executable 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
122
tools/lib.bash
Normal 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
|
||||
}
|
@ -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
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user