This commit is contained in:
Evgeny Zinoviev 2023-06-14 14:06:26 +03:00
parent 5d8e81b6c8
commit e97f98e5e2
13 changed files with 385 additions and 416 deletions

141
bin/ipcam_capture.py Executable file
View File

@ -0,0 +1,141 @@
#!/usr/bin/env python3
import __py_include
import sys
import os
import subprocess
import asyncio
import signal
from typing import TextIO
from argparse import ArgumentParser
from socket import gethostname
from asyncio.streams import StreamReader
from homekit.config import LinuxBoardsConfig, config as homekit_config
from homekit.camera import IpcamConfig, CaptureType
from homekit.camera.util import get_hls_directory, get_hls_channel_name, get_recordings_path
ipcam_config = IpcamConfig()
lbc_config = LinuxBoardsConfig()
channels = (1, 2)
tasks = []
restart_delay = 3
lock = asyncio.Lock()
worker_type: CaptureType
async def read_output(stream: StreamReader,
thread_name: str,
output: TextIO):
try:
while True:
line = await stream.readline()
if not line:
break
print(f"[{thread_name}] {line.decode().strip()}", file=output)
except asyncio.LimitOverrunError:
print(f"[{thread_name}] Output limit exceeded.", file=output)
except Exception as e:
print(f"[{thread_name}] Error occurred while reading output: {e}", file=sys.stderr)
async def run_ffmpeg(cam: int, channel: int):
prefix = get_hls_channel_name(cam, channel)
if homekit_config.app_config.logging_is_verbose():
debug_args = ['-v', '-info']
else:
debug_args = ['-nostats', '-loglevel', 'error']
protocol = 'tcp' if ipcam_config.should_use_tcp_for_rtsp(cam) else 'udp'
user, pw = ipcam_config.get_rtsp_creds()
ip = ipcam_config.get_camera_ip(cam)
path = ipcam_config.get_camera_type(cam).get_channel_url(channel)
ext = ipcam_config.get_camera_container(cam)
ffmpeg_command = ['ffmpeg', *debug_args,
'-rtsp_transport', protocol,
'-i', f'rtsp://{user}:{pw}@{ip}:554{path}',
'-c', 'copy',]
if worker_type == CaptureType.HLS:
ffmpeg_command.extend(['-bufsize', '1835k',
'-pix_fmt', 'yuv420p',
'-flags', '-global_header',
'-hls_time', '2',
'-hls_list_size', '3',
'-hls_flags', 'delete_segments',
os.path.join(get_hls_directory(cam, channel), 'live.m3u8')])
elif worker_type == CaptureType.RECORD:
ffmpeg_command.extend(['-f', 'segment',
'-strftime', '1',
'-segment_time', '00:10:00',
'-segment_atclocktime', '1',
os.path.join(get_recordings_path(cam), f'record_%Y-%m-%d-%H.%M.%S.{ext.value}')])
else:
raise ValueError(f'invalid worker type: {worker_type}')
while True:
try:
process = await asyncio.create_subprocess_exec(
*ffmpeg_command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
stdout_task = asyncio.create_task(read_output(process.stdout, prefix, sys.stdout))
stderr_task = asyncio.create_task(read_output(process.stderr, prefix, sys.stderr))
await asyncio.gather(stdout_task, stderr_task)
# check the return code of the process
if process.returncode != 0:
raise subprocess.CalledProcessError(process.returncode, ffmpeg_command)
except (FileNotFoundError, PermissionError, subprocess.CalledProcessError) as e:
# an error occurred, print the error message
error_message = f"Error occurred in {prefix}: {e}"
print(error_message, file=sys.stderr)
# sleep for 5 seconds before restarting the process
await asyncio.sleep(restart_delay)
async def run():
kwargs = {}
if worker_type == CaptureType.RECORD:
kwargs['filter_by_server'] = gethostname()
for cam in ipcam_config.get_all_cam_names(**kwargs):
for channel in channels:
task = asyncio.create_task(run_ffmpeg(cam, channel))
tasks.append(task)
try:
await asyncio.gather(*tasks)
except KeyboardInterrupt:
print('KeyboardInterrupt: stopping processes...', file=sys.stderr)
for task in tasks:
task.cancel()
# wait for subprocesses to terminate
await asyncio.gather(*tasks, return_exceptions=True)
# send termination signal to all subprocesses
for task in tasks:
process = task.get_stack()
if process:
process.send_signal(signal.SIGTERM)
if __name__ == '__main__':
capture_types = [t.value for t in CaptureType]
parser = ArgumentParser()
parser.add_argument('type', type=str, metavar='CAPTURE_TYPE', choices=tuple(capture_types),
help='capture type (variants: '+', '.join(capture_types)+')')
arg = homekit_config.load_app(no_config=True, parser=parser)
worker_type = CaptureType(arg['type'])
asyncio.run(run())

View File

@ -1,119 +0,0 @@
#!/bin/bash
PROGNAME="$0"
PORT=554
IP=
CREDS=
DEBUG=0
CHANNEL=1
FORCE_UDP=0
FORCE_TCP=0
EXTENSION="mp4"
die() {
echo >&2 "error: $@"
exit 1
}
usage() {
cat <<EOF
usage: $PROGNAME [OPTIONS] COMMAND
Options:
--outdir output directory
--ip camera IP
--port RTSP port (default: 554)
--creds
--debug
--force-tcp
--force-udp
--channel 1|2
EOF
exit
}
validate_channel() {
local c="$1"
case "$c" in
1|2)
:
;;
*)
die "Invalid channel"
;;
esac
}
[ -z "$1" ] && usage
while [[ $# -gt 0 ]]; do
case "$1" in
--ip | --port | --creds | --outdir)
_var=${1:2}
_var=${_var^^}
printf -v "$_var" '%s' "$2"
shift
;;
--debug)
DEBUG=1
;;
--force-tcp)
FORCE_TCP=1
;;
--force-udp)
FORCE_UDP=1
;;
--channel)
CHANNEL="$2"
shift
;;
--mov)
EXTENSION="mov"
;;
--mpv)
EXTENSION="mpv"
;;
*)
die "Unrecognized argument: $1"
;;
esac
shift
done
[ -z "$OUTDIR" ] && die "You must specify output directory (--outdir)."
[ -z "$IP" ] && die "You must specify camera IP address (--ip)."
[ -z "$PORT" ] && die "Port can't be empty."
[ -z "$CREDS" ] && die "You must specify credentials (--creds)."
validate_channel "$CHANNEL"
if [ ! -d "${OUTDIR}" ]; then
mkdir "${OUTDIR}" || die "Failed to create ${OUTDIR}/${NAME}!"
echo "Created $OUTDIR."
fi
args=
if [ "$DEBUG" = "1" ]; then
args="$args -v info"
else
args="$args -nostats -loglevel warning"
fi
if [ "$FORCE_TCP" = "1" ]; then
args="$args -rtsp_transport tcp"
elif [ "$FORCE_UDP" = "1" ]; then
args="$args -rtsp_transport udp"
fi
[ ! -z "$CREDS" ] && CREDS="${CREDS}@"
ffmpeg $args -i rtsp://${CREDS}${IP}:${PORT}/Streaming/Channels/${CHANNEL} \
-c copy -f segment -strftime 1 -segment_time 00:10:00 -segment_atclocktime 1 \
"$OUTDIR/record_%Y-%m-%d-%H.%M.%S.${EXTENSION}"

View File

@ -1,127 +0,0 @@
#!/bin/bash
PROGNAME="$0"
OUTDIR=/var/ipcamfs # should be tmpfs
PORT=554
NAME=
IP=
USER=
PASSWORD=
DEBUG=0
CHANNEL=1
FORCE_UDP=0
FORCE_TCP=0
CUSTOM_PATH=
die() {
echo >&2 "error: $@"
exit 1
}
usage() {
cat <<EOF
usage: $PROGNAME [OPTIONS] COMMAND
Options:
--ip camera IP
--port RTSP port (default: 554)
--name camera name (chunks will be stored under $OUTDIR/{name}/)
--user
--password
--debug
--force-tcp
--force-udp
--channel 1|2
--custom-path PATH
EOF
exit
}
validate_channel() {
local c="$1"
case "$c" in
1|2)
:
;;
*)
die "Invalid channel"
;;
esac
}
[ -z "$1" ] && usage
while [[ $# -gt 0 ]]; do
case "$1" in
--ip|--port|--name|--user|--password)
_var=${1:2}
_var=${_var^^}
printf -v "$_var" '%s' "$2"
shift
;;
--debug)
DEBUG=1
;;
--force-tcp)
FORCE_TCP=1
;;
--force-udp)
FORCE_UDP=1
;;
--channel)
CHANNEL="$2"
shift
;;
--custom-path)
CUSTOM_PATH="$2"
shift
;;
*)
die "Unrecognized argument: $1"
;;
esac
shift
done
[ -z "$IP" ] && die "You must specify camera IP address (--ip)."
[ -z "$PORT" ] && die "Port can't be empty."
[ -z "$NAME" ] && die "You must specify camera name (--name)."
[ -z "$USER" ] && die "You must specify username (--user)."
[ -z "$PASSWORD" ] && die "You must specify username (--password)."
validate_channel "$CHANNEL"
if [ ! -d "${OUTDIR}/${NAME}" ]; then
mkdir "${OUTDIR}/${NAME}" || die "Failed to create ${OUTDIR}/${NAME}!"
fi
args=
if [ "$DEBUG" = "1" ]; then
args="-v info"
else
args="-nostats -loglevel error"
fi
if [ "$FORCE_TCP" = "1" ]; then
args="$args -rtsp_transport tcp"
elif [ "$FORCE_UDP" = "1" ]; then
args="$args -rtsp_transport udp"
fi
if [ -z "$CUSTOM_PATH" ]; then
path="/Streaming/Channels/${CHANNEL}"
else
path="$CUSTOM_PATH"
fi
ffmpeg $args -i "rtsp://${USER}:${PASSWORD}@${IP}:${PORT}${path}" \
-c:v copy -c:a copy -bufsize 1835k \
-pix_fmt yuv420p \
-flags -global_header -hls_time 2 -hls_list_size 3 -hls_flags delete_segments \
${OUTDIR}/${NAME}/live.m3u8

View File

@ -1,7 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import logging import logging
import os import os
import re
import asyncio import asyncio
import time import time
import shutil import shutil
@ -9,49 +8,41 @@ import __py_include
import homekit.telegram.aio as telegram import homekit.telegram.aio as telegram
from socket import gethostname
from argparse import ArgumentParser from argparse import ArgumentParser
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
from asyncio import Lock from asyncio import Lock
from homekit.config import config from homekit.config import config as homekit_config, LinuxBoardsConfig
from homekit.util import Addr
from homekit import http from homekit import http
from homekit.database.sqlite import SQLiteBase from homekit.database.sqlite import SQLiteBase
from homekit.camera import util as camutil from homekit.camera import util as camutil, IpcamConfig
from homekit.camera.types import (
TimeFilterType,
TelegramLinkType,
VideoContainerType
)
from homekit.camera.util import (
get_recordings_path,
get_motion_path,
is_valid_recording_name,
datetime_from_filename
)
from enum import Enum
from typing import Optional, Union, List, Tuple from typing import Optional, Union, List, Tuple
from datetime import datetime, timedelta from datetime import datetime, timedelta
from functools import cmp_to_key from functools import cmp_to_key
class TimeFilterType(Enum): ipcam_config = IpcamConfig()
FIX = 'fix' lbc_config = LinuxBoardsConfig()
MOTION = 'motion'
MOTION_START = 'motion_start'
class TelegramLinkType(Enum):
FRAGMENT = 'fragment'
ORIGINAL_FILE = 'original_file'
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)
def get_all_cams() -> list:
return [cam for cam in config['camera'].keys()]
# ipcam database # ipcam database
# -------------- # --------------
class IPCamServerDatabase(SQLiteBase): class IpcamServerDatabase(SQLiteBase):
SCHEMA = 4 SCHEMA = 4
def __init__(self, path=None): def __init__(self, path=None):
@ -67,7 +58,7 @@ class IPCamServerDatabase(SQLiteBase):
fix_time INTEGER NOT NULL, fix_time INTEGER NOT NULL,
motion_time INTEGER NOT NULL motion_time INTEGER NOT NULL
)""") )""")
for cam in config['camera'].keys(): for cam in ipcam_config.get_all_cam_names_for_this_server():
self.add_camera(cam) self.add_camera(cam)
if version < 2: if version < 2:
@ -135,7 +126,7 @@ class IPCamServerDatabase(SQLiteBase):
# ipcam web api # ipcam web api
# ------------- # -------------
class IPCamWebServer(http.HTTPServer): class IpcamWebServer(http.HTTPServer):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -146,16 +137,16 @@ class IPCamWebServer(http.HTTPServer):
self.get('/api/timestamp/{name}/{type}', self.get_timestamp) self.get('/api/timestamp/{name}/{type}', self.get_timestamp)
self.get('/api/timestamp/all', self.get_all_timestamps) 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/debug/fix', self.debug_fix)
self.post('/api/debug/cleanup', self.debug_cleanup) self.post('/api/debug/cleanup', self.debug_cleanup)
self.post('/api/timestamp/{name}/{type}', self.set_timestamp) self.post('/api/timestamp/{name}/{type}', self.set_timestamp)
self.post('/api/motion/done/{name}', self.submit_motion) self.post('/api/motion/done/{name}', self.submit_motion)
self.post('/api/motion/fail/{name}', self.submit_motion_failure) self.post('/api/motion/fail/{name}', self.submit_motion_failure)
self.get('/api/motion/params/{name}', self.get_motion_params) # self.get('/api/motion/params/{name}', self.get_motion_params)
self.get('/api/motion/params/{name}/roi', self.get_motion_roi_params) # self.get('/api/motion/params/{name}/roi', self.get_motion_roi_params)
self.queue_lock = Lock() self.queue_lock = Lock()
@ -173,7 +164,7 @@ class IPCamWebServer(http.HTTPServer):
files = get_recordings_files(camera, filter, limit) files = get_recordings_files(camera, filter, limit)
if files: if files:
time = filename_to_datetime(files[len(files)-1]['name']) time = datetime_from_filename(files[len(files)-1]['name'])
db.set_timestamp(camera, TimeFilterType.MOTION_START, time) db.set_timestamp(camera, TimeFilterType.MOTION_START, time)
return self.ok({'files': files}) return self.ok({'files': files})
@ -188,7 +179,7 @@ class IPCamWebServer(http.HTTPServer):
if files: if files:
times_by_cam = {} times_by_cam = {}
for file in files: for file in files:
time = filename_to_datetime(file['name']) time = datetime_from_filename(file['name'])
if file['cam'] not in times_by_cam or times_by_cam[file['cam']] < time: if file['cam'] not in times_by_cam or times_by_cam[file['cam']] < time:
times_by_cam[file['cam']] = time times_by_cam[file['cam']] = time
for cam, time in times_by_cam.items(): for cam, time in times_by_cam.items():
@ -200,14 +191,14 @@ class IPCamWebServer(http.HTTPServer):
cam = int(req.match_info['name']) cam = int(req.match_info['name'])
file = req.match_info['file'] file = req.match_info['file']
fullpath = os.path.join(config['camera'][cam]['recordings_path'], file) fullpath = os.path.join(get_recordings_path(cam), file)
if not os.path.isfile(fullpath): if not os.path.isfile(fullpath):
raise ValueError(f'file "{fullpath}" does not exists') raise ValueError(f'file "{fullpath}" does not exists')
return http.FileResponse(fullpath) return http.FileResponse(fullpath)
async def camlist(self, req: http.Request): async def camlist(self, req: http.Request):
return self.ok(config['camera']) return self.ok(ipcam_config.get_all_cam_names_for_this_server())
async def submit_motion(self, req: http.Request): async def submit_motion(self, req: http.Request):
data = await req.post() data = await req.post()
@ -216,7 +207,7 @@ class IPCamWebServer(http.HTTPServer):
timecodes = data['timecodes'] timecodes = data['timecodes']
filename = data['filename'] filename = data['filename']
time = filename_to_datetime(filename) time = datetime_from_filename(filename)
try: try:
if timecodes != '': if timecodes != '':
@ -239,27 +230,10 @@ class IPCamWebServer(http.HTTPServer):
message = data['message'] message = data['message']
db.add_motion_failure(camera, filename, message) db.add_motion_failure(camera, filename, message)
db.set_timestamp(camera, TimeFilterType.MOTION, filename_to_datetime(filename)) db.set_timestamp(camera, TimeFilterType.MOTION, datetime_from_filename(filename))
return self.ok() 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): async def debug_fix(self, req: http.Request):
asyncio.ensure_future(fix_job()) asyncio.ensure_future(fix_job())
return self.ok() return self.ok()
@ -280,26 +254,26 @@ class IPCamWebServer(http.HTTPServer):
async def get_all_timestamps(self, req: http.Request): async def get_all_timestamps(self, req: http.Request):
return self.ok(db.get_all_timestamps()) return self.ok(db.get_all_timestamps())
async def get_motion_params(self, req: http.Request): # async def get_motion_params(self, req: http.Request):
data = config['motion_params'][int(req.match_info['name'])] # data = config['motion_params'][int(req.match_info['name'])]
lines = [ # lines = [
f'threshold={data["threshold"]}', # f'threshold={data["threshold"]}',
f'min_event_length=3s', # f'min_event_length=3s',
f'frame_skip=2', # f'frame_skip=2',
f'downscale_factor=3', # f'downscale_factor=3',
] # ]
return self.plain('\n'.join(lines)+'\n') # return self.plain('\n'.join(lines)+'\n')
#
async def get_motion_roi_params(self, req: http.Request): # async def get_motion_roi_params(self, req: http.Request):
data = config['motion_params'][int(req.match_info['name'])] # data = config['motion_params'][int(req.match_info['name'])]
return self.plain('\n'.join(data['roi'])+'\n') # return self.plain('\n'.join(data['roi'])+'\n')
@staticmethod @staticmethod
def _getset_timestamp_params(req: http.Request, need_time=False): def _getset_timestamp_params(req: http.Request, need_time=False):
values = [] values = []
cam = int(req.match_info['name']) cam = int(req.match_info['name'])
assert cam in config['camera'], 'invalid camera' assert cam in ipcam_config.get_all_cam_names_for_this_server(), 'invalid camera'
values.append(cam) values.append(cam)
values.append(TimeFilterType(req.match_info['type'])) values.append(TimeFilterType(req.match_info['type']))
@ -307,7 +281,7 @@ class IPCamWebServer(http.HTTPServer):
if need_time: if need_time:
time = req.query['time'] time = req.query['time']
if time.startswith('record_'): if time.startswith('record_'):
time = filename_to_datetime(time) time = datetime_from_filename(time)
elif time.isnumeric(): elif time.isnumeric():
time = int(time) time = int(time)
else: else:
@ -322,30 +296,22 @@ class IPCamWebServer(http.HTTPServer):
def open_database(database_path: str): def open_database(database_path: str):
global db global db
db = IPCamServerDatabase(database_path) db = IpcamServerDatabase(database_path)
# update cams list in database, if needed # update cams list in database, if needed
cams = db.get_all_timestamps().keys() stored_cams = db.get_all_timestamps().keys()
for cam in config['camera']: for cam in ipcam_config.get_all_cam_names_for_this_server():
if cam not in cams: if cam not in stored_cams:
db.add_camera(cam) 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: Optional[int] = None, def get_recordings_files(cam: Optional[int] = None,
time_filter_type: Optional[TimeFilterType] = None, time_filter_type: Optional[TimeFilterType] = None,
limit=0) -> List[dict]: limit=0) -> List[dict]:
from_time = 0 from_time = 0
to_time = int(time.time()) to_time = int(time.time())
cams = [cam] if cam is not None else get_all_cams() cams = [cam] if cam is not None else ipcam_config.get_all_cam_names_for_this_server()
files = [] files = []
for cam in cams: for cam in cams:
if time_filter_type: if time_filter_type:
@ -362,7 +328,7 @@ def get_recordings_files(cam: Optional[int] = None,
'name': file, 'name': file,
'size': os.path.getsize(os.path.join(recdir, file))} 'size': os.path.getsize(os.path.join(recdir, file))}
for file in os.listdir(recdir) for file in os.listdir(recdir)
if valid_recording_name(file) and from_time < filename_to_datetime(file) <= to_time] if is_valid_recording_name(file) and from_time < datetime_from_filename(file) <= to_time]
cam_files.sort(key=lambda file: file['name']) cam_files.sort(key=lambda file: file['name'])
if cam_files: if cam_files:
@ -382,7 +348,7 @@ def get_recordings_files(cam: Optional[int] = None,
async def process_fragments(camera: int, async def process_fragments(camera: int,
filename: str, filename: str,
fragments: List[Tuple[int, int]]) -> None: fragments: List[Tuple[int, int]]) -> None:
time = filename_to_datetime(filename) time = datetime_from_filename(filename)
rec_dir = get_recordings_path(camera) rec_dir = get_recordings_path(camera)
motion_dir = get_motion_path(camera) motion_dir = get_motion_path(camera)
@ -392,8 +358,8 @@ async def process_fragments(camera: int,
for fragment in fragments: for fragment in fragments:
start, end = fragment start, end = fragment
start -= config['motion']['padding'] start -= ipcam_config['motion_padding']
end += config['motion']['padding'] end += ipcam_config['motion_padding']
if start < 0: if start < 0:
start = 0 start = 0
@ -408,14 +374,14 @@ async def process_fragments(camera: int,
start_pos=start, start_pos=start,
duration=duration) duration=duration)
if fragments and 'telegram' in config['motion'] and config['motion']['telegram']: if fragments and ipcam_config['motion_telegram']:
asyncio.ensure_future(motion_notify_tg(camera, filename, fragments)) asyncio.ensure_future(motion_notify_tg(camera, filename, fragments))
async def motion_notify_tg(camera: int, async def motion_notify_tg(camera: int,
filename: str, filename: str,
fragments: List[Tuple[int, int]]): fragments: List[Tuple[int, int]]):
dt_file = filename_to_datetime(filename) dt_file = datetime_from_filename(filename)
fmt = '%H:%M:%S' fmt = '%H:%M:%S'
text = f'Camera: <b>{camera}</b>\n' text = f'Camera: <b>{camera}</b>\n'
@ -423,8 +389,8 @@ async def motion_notify_tg(camera: int,
text += _tg_links(TelegramLinkType.ORIGINAL_FILE, camera, filename) text += _tg_links(TelegramLinkType.ORIGINAL_FILE, camera, filename)
for start, end in fragments: for start, end in fragments:
start -= config['motion']['padding'] start -= ipcam_config['motion_padding']
end += config['motion']['padding'] end += ipcam_config['motion_padding']
if start < 0: if start < 0:
start = 0 start = 0
@ -446,7 +412,7 @@ def _tg_links(link_type: TelegramLinkType,
camera: int, camera: int,
file: str) -> str: file: str) -> str:
links = [] links = []
for link_name, link_template in config['telegram'][f'{link_type.value}_url_templates']: for link_name, link_template in ipcam_config[f'{link_type.value}_url_templates']:
link = link_template.replace('{camera}', str(camera)).replace('{file}', file) link = link_template.replace('{camera}', str(camera)).replace('{file}', file)
links.append(f'<a href="{link}">{link_name}</a>') links.append(f'<a href="{link}">{link_name}</a>')
return ' '.join(links) return ' '.join(links)
@ -462,7 +428,7 @@ async def fix_job() -> None:
try: try:
fix_job_running = True fix_job_running = True
for cam in config['camera'].keys(): for cam in ipcam_config.get_all_cam_names_for_this_server():
files = get_recordings_files(cam, TimeFilterType.FIX) files = get_recordings_files(cam, TimeFilterType.FIX)
if not files: if not files:
logger.debug(f'fix_job: no files for camera {cam}') logger.debug(f'fix_job: no files for camera {cam}')
@ -473,7 +439,7 @@ async def fix_job() -> None:
for file in files: for file in files:
fullpath = os.path.join(get_recordings_path(cam), file['name']) fullpath = os.path.join(get_recordings_path(cam), file['name'])
await camutil.ffmpeg_recreate(fullpath) await camutil.ffmpeg_recreate(fullpath)
timestamp = filename_to_datetime(file['name']) timestamp = datetime_from_filename(file['name'])
if timestamp: if timestamp:
db.set_timestamp(cam, TimeFilterType.FIX, timestamp) db.set_timestamp(cam, TimeFilterType.FIX, timestamp)
@ -482,21 +448,9 @@ async def fix_job() -> None:
async def cleanup_job() -> None: async def cleanup_job() -> None:
def fn2dt(name: str) -> datetime:
name = os.path.basename(name)
if name.startswith('record_'):
return datetime.strptime(re.match(r'record_(.*?)\.mp4', name).group(1), datetime_format)
m = re.match(rf'({datetime_format_re})__{datetime_format_re}\.mp4', name)
if m:
return datetime.strptime(m.group(1), datetime_format)
raise ValueError(f'unrecognized filename format: {name}')
def compare(i1: str, i2: str) -> int: def compare(i1: str, i2: str) -> int:
dt1 = fn2dt(i1) dt1 = datetime_from_filename(i1)
dt2 = fn2dt(i2) dt2 = datetime_from_filename(i2)
if dt1 < dt2: if dt1 < dt2:
return -1 return -1
@ -516,18 +470,19 @@ async def cleanup_job() -> None:
cleanup_job_running = True cleanup_job_running = True
gb = float(1 << 30) gb = float(1 << 30)
for storage in config['storages']: disk_number = 0
for storage in lbc_config.get_board_disks(gethostname()):
disk_number += 1
if os.path.exists(storage['mountpoint']): if os.path.exists(storage['mountpoint']):
total, used, free = shutil.disk_usage(storage['mountpoint']) total, used, free = shutil.disk_usage(storage['mountpoint'])
free_gb = free // gb free_gb = free // gb
if free_gb < config['cleanup_min_gb']: if free_gb < ipcam_config['cleanup_min_gb']:
# print(f"{storage['mountpoint']}: free={free}, free_gb={free_gb}")
cleaned = 0 cleaned = 0
files = [] files = []
for cam in storage['cams']: for cam in ipcam_config.get_all_cam_names_for_this_server(filter_by_disk=disk_number):
for _dir in (config['camera'][cam]['recordings_path'], config['camera'][cam]['motion_path']): for _dir in (get_recordings_path(cam), get_motion_path(cam)):
files += list(map(lambda file: os.path.join(_dir, file), os.listdir(_dir))) files += list(map(lambda file: os.path.join(_dir, file), os.listdir(_dir)))
files = list(filter(lambda path: os.path.isfile(path) and path.endswith('.mp4'), files)) files = list(filter(lambda path: os.path.isfile(path) and path.endswith(tuple([f'.{t.value}' for t in VideoContainerType])), files))
files.sort(key=cmp_to_key(compare)) files.sort(key=cmp_to_key(compare))
for file in files: for file in files:
@ -537,7 +492,7 @@ async def cleanup_job() -> None:
cleaned += size cleaned += size
except OSError as e: except OSError as e:
logger.exception(e) logger.exception(e)
if (free + cleaned) // gb >= config['cleanup_min_gb']: if (free + cleaned) // gb >= ipcam_config['cleanup_min_gb']:
break break
else: else:
logger.error(f"cleanup_job: {storage['mountpoint']} not found") logger.error(f"cleanup_job: {storage['mountpoint']} not found")
@ -550,8 +505,8 @@ cleanup_job_running = False
datetime_format = '%Y-%m-%d-%H.%M.%S' datetime_format = '%Y-%m-%d-%H.%M.%S'
datetime_format_re = r'\d{4}-\d{2}-\d{2}-\d{2}\.\d{2}.\d{2}' datetime_format_re = r'\d{4}-\d{2}-\d{2}-\d{2}\.\d{2}.\d{2}'
db: Optional[IPCamServerDatabase] = None db: Optional[IpcamServerDatabase] = None
server: Optional[IPCamWebServer] = None server: Optional[IpcamWebServer] = None
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -562,7 +517,7 @@ if __name__ == '__main__':
parser = ArgumentParser() parser = ArgumentParser()
parser.add_argument('--listen', type=str, required=True) parser.add_argument('--listen', type=str, required=True)
parser.add_argument('--database-path', type=str, required=True) parser.add_argument('--database-path', type=str, required=True)
arg = config.load_app(no_config=True, parser=parser) arg = homekit_config.load_app(no_config=True, parser=parser)
open_database(arg.database_path) open_database(arg.database_path)
@ -570,10 +525,14 @@ if __name__ == '__main__':
try: try:
scheduler = AsyncIOScheduler(event_loop=loop) scheduler = AsyncIOScheduler(event_loop=loop)
if config['fix_enabled']: if ipcam_config['fix_enabled']:
scheduler.add_job(fix_job, 'interval', seconds=config['fix_interval'], misfire_grace_time=None) scheduler.add_job(fix_job, 'interval',
seconds=ipcam_config['fix_interval'],
misfire_grace_time=None)
scheduler.add_job(cleanup_job, 'interval', seconds=config['cleanup_interval'], misfire_grace_time=None) scheduler.add_job(cleanup_job, 'interval',
seconds=ipcam_config['cleanup_interval'],
misfire_grace_time=None)
scheduler.start() scheduler.start()
except KeyError: except KeyError:
pass pass
@ -581,5 +540,5 @@ if __name__ == '__main__':
asyncio.ensure_future(fix_job()) asyncio.ensure_future(fix_job())
asyncio.ensure_future(cleanup_job()) asyncio.ensure_future(cleanup_job())
server = IPCamWebServer(config.get_addr('server.listen')) server = IpcamWebServer(Addr.fromstring(arg.listen))
server.run() server.run()

View File

@ -42,7 +42,6 @@ class WebAPIServer(http.HTTPServer):
self.get('/sound_sensors/hits/', self.GET_sound_sensors_hits) self.get('/sound_sensors/hits/', self.GET_sound_sensors_hits)
self.post('/sound_sensors/hits/', self.POST_sound_sensors_hits) self.post('/sound_sensors/hits/', self.POST_sound_sensors_hits)
self.post('/log/bot_request/', self.POST_bot_request_log)
self.post('/log/openwrt/', self.POST_openwrt_log) self.post('/log/openwrt/', self.POST_openwrt_log)
self.get('/inverter/consumed_energy/', self.GET_consumed_energy) self.get('/inverter/consumed_energy/', self.GET_consumed_energy)

View File

@ -1,6 +1,6 @@
import subprocess import subprocess
from ..config import app_config as config from ..config import config
from threading import Lock from threading import Lock
from typing import Union, List from typing import Union, List
@ -10,14 +10,14 @@ _default_step = 5
def has_control(s: str) -> bool: def has_control(s: str) -> bool:
for control in config['amixer']['controls']: for control in config.app_config['amixer']['controls']:
if control['name'] == s: if control['name'] == s:
return True return True
return False return False
def get_caps(s: str) -> List[str]: def get_caps(s: str) -> List[str]:
for control in config['amixer']['controls']: for control in config.app_config['amixer']['controls']:
if control['name'] == s: if control['name'] == s:
return control['caps'] return control['caps']
raise KeyError(f'control {s} not found') raise KeyError(f'control {s} not found')
@ -25,7 +25,7 @@ def get_caps(s: str) -> List[str]:
def get_all() -> list: def get_all() -> list:
controls = [] controls = []
for control in config['amixer']['controls']: for control in config.app_config['amixer']['controls']:
controls.append({ controls.append({
'name': control['name'], 'name': control['name'],
'info': get(control['name']), 'info': get(control['name']),
@ -55,8 +55,8 @@ def nocap(control):
def _get_default_step() -> int: def _get_default_step() -> int:
if 'step' in config['amixer']: if 'step' in config.app_config['amixer']:
return int(config['amixer']['step']) return int(config.app_config['amixer']['step'])
return _default_step return _default_step
@ -75,7 +75,7 @@ def decr(control, step=None):
def call(*args, return_code=False) -> Union[int, str]: def call(*args, return_code=False) -> Union[int, str]:
with _lock: with _lock:
result = subprocess.run([config['amixer']['bin'], *args], result = subprocess.run([config.app_config['amixer']['bin'], *args],
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE) stderr=subprocess.PIPE)
if return_code: if return_code:

View File

@ -1 +1,2 @@
from .types import CameraType from .types import CameraType, VideoContainerType, VideoCodecType, CaptureType
from .config import IpcamConfig

View File

@ -1,8 +1,9 @@
import socket
from ..config import ConfigUnit, LinuxBoardsConfig from ..config import ConfigUnit, LinuxBoardsConfig
from typing import Optional from typing import Optional
from .types import CameraType, VideoContainerType, VideoCodecType from .types import CameraType, VideoContainerType, VideoCodecType
_lbc = LinuxBoardsConfig() _lbc = LinuxBoardsConfig()
@ -42,7 +43,8 @@ class IpcamConfig(ConfigUnit):
'schema': {'type': 'string', 'check_with': _validate_roi_line} 'schema': {'type': 'string', 'check_with': _validate_roi_line}
} }
} }
} },
'rtsp_tcp': {'type': 'boolean'}
} }
} }
}, },
@ -55,7 +57,19 @@ class IpcamConfig(ConfigUnit):
# TODO FIXME # TODO FIXME
'fragment_url_templates': cls._url_templates_schema(), 'fragment_url_templates': cls._url_templates_schema(),
'original_file_url_templates': cls._url_templates_schema() 'original_file_url_templates': cls._url_templates_schema(),
'hls_path': {'type': 'string', 'required': True},
'motion_processing_tmpfs_path': {'type': 'string', 'required': True},
'rtsp_creds': {
'required': True,
'type': 'dict',
'schema': {
'login': {'type': 'string', 'required': True},
'password': {'type': 'string', 'required': True},
}
}
} }
@staticmethod @staticmethod
@ -79,3 +93,38 @@ class IpcamConfig(ConfigUnit):
'schema': {'type': 'string'} 'schema': {'type': 'string'}
} }
} }
def get_all_cam_names(self,
filter_by_server: Optional[str] = None,
filter_by_disk: Optional[int] = None) -> list[int]:
cams = []
if filter_by_server is not None and filter_by_server not in _lbc:
raise ValueError(f'invalid filter_by_server: {filter_by_server} not found in {_lbc.__class__.__name__}')
for cam, params in self['cams'].items():
if filter_by_server is None or params['server'] == filter_by_server:
if filter_by_disk is None or params['disk'] == filter_by_disk:
cams.append(int(cam))
return cams
def get_all_cam_names_for_this_server(self,
filter_by_disk: Optional[int] = None):
return self.get_all_cam_names(filter_by_server=socket.gethostname(),
filter_by_disk=filter_by_disk)
def get_cam_server_and_disk(self, cam: int) -> tuple[str, int]:
return self['cams'][cam]['server'], self['cams'][cam]['disk']
def get_camera_container(self, cam: int) -> VideoContainerType:
return VideoContainerType(self['cams'][cam]['container'])
def get_camera_type(self, cam: int) -> CameraType:
return CameraType(self['cams'][cam]['type'])
def get_rtsp_creds(self) -> tuple[str, str]:
return self['rtsp_creds']['login'], self['rtsp_creds']['password']
def should_use_tcp_for_rtsp(self, cam: int) -> bool:
return 'rtsp_tcp' in self['cams'][cam] and self['cams'][cam]['rtsp_tcp']
def get_camera_ip(self, camera: int) -> str:
return f'192.168.5.{camera}'

View File

@ -6,6 +6,19 @@ class CameraType(Enum):
ALIEXPRESS_NONAME = 'ali' ALIEXPRESS_NONAME = 'ali'
HIKVISION = 'hik' HIKVISION = 'hik'
def get_channel_url(self, channel: int) -> str:
if channel not in (1, 2):
raise ValueError(f'channel {channel} is invalid')
if channel == 1:
return ''
elif channel == 2:
if self.value == CameraType.HIKVISION:
return '/Streaming/Channels/2'
elif self.value == CameraType.ALIEXPRESS_NONAME:
return '/?stream=1.sdp'
else:
raise ValueError(f'unsupported camera type {self.value}')
class VideoContainerType(Enum): class VideoContainerType(Enum):
MP4 = 'mp4' MP4 = 'mp4'
@ -15,3 +28,19 @@ class VideoContainerType(Enum):
class VideoCodecType(Enum): class VideoCodecType(Enum):
H264 = 'h264' H264 = 'h264'
H265 = 'h265' H265 = 'h265'
class TimeFilterType(Enum):
FIX = 'fix'
MOTION = 'motion'
MOTION_START = 'motion_start'
class TelegramLinkType(Enum):
FRAGMENT = 'fragment'
ORIGINAL_FILE = 'original_file'
class CaptureType(Enum):
HLS = 'hls'
RECORD = 'record'

View File

@ -2,13 +2,21 @@ import asyncio
import os.path import os.path
import logging import logging
import psutil import psutil
import re
from datetime import datetime
from typing import List, Tuple from typing import List, Tuple
from ..util import chunks from ..util import chunks
from ..config import config from ..config import config, LinuxBoardsConfig
from .config import IpcamConfig
from .types import VideoContainerType
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
_temporary_fixing = '.temporary_fixing.mp4' _ipcam_config = IpcamConfig()
_lbc_config = LinuxBoardsConfig()
datetime_format = '%Y-%m-%d-%H.%M.%S'
datetime_format_re = r'\d{4}-\d{2}-\d{2}-\d{2}\.\d{2}.\d{2}'
def _get_ffmpeg_path() -> str: def _get_ffmpeg_path() -> str:
@ -26,7 +34,8 @@ def time2seconds(time: str) -> int:
async def ffmpeg_recreate(filename: str): async def ffmpeg_recreate(filename: str):
filedir = os.path.dirname(filename) filedir = os.path.dirname(filename)
tempname = os.path.join(filedir, _temporary_fixing) _, fileext = os.path.splitext(filename)
tempname = os.path.join(filedir, f'.temporary_fixing.{fileext}')
mtime = os.path.getmtime(filename) mtime = os.path.getmtime(filename)
args = [_get_ffmpeg_path(), '-nostats', '-loglevel', 'error', '-i', filename, '-c', 'copy', '-y', tempname] args = [_get_ffmpeg_path(), '-nostats', '-loglevel', 'error', '-i', filename, '-c', 'copy', '-y', tempname]
@ -105,3 +114,56 @@ def has_handle(fpath):
pass pass
return False return False
def get_recordings_path(cam: int) -> str:
server, disk = _ipcam_config.get_cam_server_and_disk(cam)
disks = _lbc_config.get_board_disks(server)
disk_mountpoint = disks[disk-1]
return f'{disk_mountpoint}/cam-{cam}'
def get_motion_path(cam: int) -> str:
return f'{get_recordings_path(cam)}/motion'
def is_valid_recording_name(filename: str) -> bool:
if not filename.startswith('record_'):
return False
for container_type in VideoContainerType:
if filename.endswith(f'.{container_type.value}'):
return True
return False
def datetime_from_filename(name: str) -> datetime:
name = os.path.basename(name)
exts = '|'.join([t.value for t in VideoContainerType])
if name.startswith('record_'):
return datetime.strptime(re.match(rf'record_(.*?)\.(?:{exts})', name).group(1), datetime_format)
m = re.match(rf'({datetime_format_re})__{datetime_format_re}\.(?:{exts})', name)
if m:
return datetime.strptime(m.group(1), datetime_format)
raise ValueError(f'unrecognized filename format: {name}')
def get_hls_channel_name(cam: int, channel: int) -> str:
name = str(cam)
if channel == 2:
name += '-low'
return name
def get_hls_directory(cam, channel) -> str:
dirname = os.path.join(
_ipcam_config['hls_path'],
get_hls_channel_name(cam, channel)
)
if not os.path.exists(dirname):
os.makedirs(dirname)
return dirname

View File

@ -53,3 +53,9 @@ class LinuxBoardsConfig(ConfigUnit):
}, },
} }
} }
def get_board_disks(self, name: str) -> list[dict]:
return self[name]['ext_hdd']
def get_board_disks_count(self, name: str) -> int:
return len(self[name]['ext_hdd'])

View File

@ -1,15 +0,0 @@
[Unit]
Description=save ipcam streams
After=network-online.target
[Service]
Restart=always
RestartSec=3
User=user
Group=user
EnvironmentFile=/etc/ipcam_capture.conf.d/%i.conf
ExecStart=/home/user/homekit/bin/ipcam_capture.sh --outdir $OUTDIR --creds $CREDS --ip $IP --port $PORT $ARGS
Restart=always
[Install]
WantedBy=multi-user.target

View File

@ -1,16 +0,0 @@
[Unit]
Description=convert rtsp to hls for viewing live camera feeds in browser
After=network-online.target
[Service]
Restart=always
RestartSec=3
User=user
Group=user
EnvironmentFile=/etc/ipcam_rtsp2hls.conf.d/%i.conf
ExecStart=/home/user/homekit/bin/ipcam_rtsp2hls.sh --name %i --user $USER --password $PASSWORD --ip $IP --port $PORT $ARGS
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target