diff options
author | Evgeny Zinoviev <me@ch1p.io> | 2023-06-14 14:06:26 +0300 |
---|---|---|
committer | Evgeny Zinoviev <me@ch1p.io> | 2023-06-19 23:19:35 +0300 |
commit | e97f98e5e27a6df3827564cce594f27f18c89267 (patch) | |
tree | 0490a13b5df2d9e1fe9d5d6039401b234be99b01 | |
parent | 5d8e81b6c8fc7abe75188007c6a86bb501a314ad (diff) |
wip
-rwxr-xr-x | bin/ipcam_capture.py | 141 | ||||
-rwxr-xr-x | bin/ipcam_capture.sh | 119 | ||||
-rwxr-xr-x | bin/ipcam_rtsp2hls.sh | 127 | ||||
-rwxr-xr-x | bin/ipcam_server.py | 205 | ||||
-rwxr-xr-x | bin/web_api.py | 1 | ||||
-rw-r--r-- | include/py/homekit/audio/amixer.py | 14 | ||||
-rw-r--r-- | include/py/homekit/camera/__init__.py | 3 | ||||
-rw-r--r-- | include/py/homekit/camera/config.py | 57 | ||||
-rw-r--r-- | include/py/homekit/camera/types.py | 29 | ||||
-rw-r--r-- | include/py/homekit/camera/util.py | 70 | ||||
-rw-r--r-- | include/py/homekit/config/_configs.py | 6 | ||||
-rw-r--r-- | systemd/ipcam_capture@.service | 15 | ||||
-rw-r--r-- | systemd/ipcam_rtsp2hls@.service | 16 |
13 files changed, 386 insertions, 417 deletions
diff --git a/bin/ipcam_capture.py b/bin/ipcam_capture.py new file mode 100755 index 0000000..5de14af --- /dev/null +++ b/bin/ipcam_capture.py @@ -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()) diff --git a/bin/ipcam_capture.sh b/bin/ipcam_capture.sh deleted file mode 100755 index b97c856..0000000 --- a/bin/ipcam_capture.sh +++ /dev/null @@ -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}" diff --git a/bin/ipcam_rtsp2hls.sh b/bin/ipcam_rtsp2hls.sh deleted file mode 100755 index c321820..0000000 --- a/bin/ipcam_rtsp2hls.sh +++ /dev/null @@ -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 diff --git a/bin/ipcam_server.py b/bin/ipcam_server.py index a9d6a0b..71d5ea1 100755 --- a/bin/ipcam_server.py +++ b/bin/ipcam_server.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 import logging import os -import re import asyncio import time import shutil @@ -9,49 +8,41 @@ import __py_include import homekit.telegram.aio as telegram +from socket import gethostname from argparse import ArgumentParser from apscheduler.schedulers.asyncio import AsyncIOScheduler 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.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 datetime import datetime, timedelta from functools import cmp_to_key -class TimeFilterType(Enum): - FIX = 'fix' - 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_config = IpcamConfig() +lbc_config = LinuxBoardsConfig() # ipcam database # -------------- -class IPCamServerDatabase(SQLiteBase): +class IpcamServerDatabase(SQLiteBase): SCHEMA = 4 def __init__(self, path=None): @@ -67,7 +58,7 @@ class IPCamServerDatabase(SQLiteBase): fix_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) if version < 2: @@ -135,7 +126,7 @@ class IPCamServerDatabase(SQLiteBase): # ipcam web api # ------------- -class IPCamWebServer(http.HTTPServer): +class IpcamWebServer(http.HTTPServer): def __init__(self, *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/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/cleanup', self.debug_cleanup) + 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) - 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}', self.get_motion_params) + # self.get('/api/motion/params/{name}/roi', self.get_motion_roi_params) self.queue_lock = Lock() @@ -173,7 +164,7 @@ class IPCamWebServer(http.HTTPServer): files = get_recordings_files(camera, filter, limit) 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) return self.ok({'files': files}) @@ -188,7 +179,7 @@ class IPCamWebServer(http.HTTPServer): if files: times_by_cam = {} 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: times_by_cam[file['cam']] = time for cam, time in times_by_cam.items(): @@ -200,14 +191,14 @@ class IPCamWebServer(http.HTTPServer): cam = int(req.match_info['name']) 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): raise ValueError(f'file "{fullpath}" does not exists') return http.FileResponse(fullpath) 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): data = await req.post() @@ -216,7 +207,7 @@ class IPCamWebServer(http.HTTPServer): timecodes = data['timecodes'] filename = data['filename'] - time = filename_to_datetime(filename) + time = datetime_from_filename(filename) try: if timecodes != '': @@ -239,27 +230,10 @@ class IPCamWebServer(http.HTTPServer): message = data['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() - 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() @@ -280,26 +254,26 @@ class IPCamWebServer(http.HTTPServer): async def get_all_timestamps(self, req: http.Request): return self.ok(db.get_all_timestamps()) - async def get_motion_params(self, req: http.Request): - data = config['motion_params'][int(req.match_info['name'])] - lines = [ - f'threshold={data["threshold"]}', - f'min_event_length=3s', - f'frame_skip=2', - f'downscale_factor=3', - ] - return self.plain('\n'.join(lines)+'\n') - - async def get_motion_roi_params(self, req: http.Request): - data = config['motion_params'][int(req.match_info['name'])] - return self.plain('\n'.join(data['roi'])+'\n') + # async def get_motion_params(self, req: http.Request): + # data = config['motion_params'][int(req.match_info['name'])] + # lines = [ + # f'threshold={data["threshold"]}', + # f'min_event_length=3s', + # f'frame_skip=2', + # f'downscale_factor=3', + # ] + # return self.plain('\n'.join(lines)+'\n') + # + # async def get_motion_roi_params(self, req: http.Request): + # data = config['motion_params'][int(req.match_info['name'])] + # return self.plain('\n'.join(data['roi'])+'\n') @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' + assert cam in ipcam_config.get_all_cam_names_for_this_server(), 'invalid camera' values.append(cam) values.append(TimeFilterType(req.match_info['type'])) @@ -307,7 +281,7 @@ class IPCamWebServer(http.HTTPServer): if need_time: time = req.query['time'] if time.startswith('record_'): - time = filename_to_datetime(time) + time = datetime_from_filename(time) elif time.isnumeric(): time = int(time) else: @@ -322,30 +296,22 @@ class IPCamWebServer(http.HTTPServer): def open_database(database_path: str): global db - db = IPCamServerDatabase(database_path) + db = IpcamServerDatabase(database_path) # update cams list in database, if needed - cams = db.get_all_timestamps().keys() - for cam in config['camera']: - if cam not in cams: + stored_cams = db.get_all_timestamps().keys() + for cam in ipcam_config.get_all_cam_names_for_this_server(): + if cam not in stored_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: Optional[int] = None, time_filter_type: Optional[TimeFilterType] = None, limit=0) -> List[dict]: from_time = 0 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 = [] for cam in cams: if time_filter_type: @@ -362,7 +328,7 @@ def get_recordings_files(cam: Optional[int] = None, '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] + if is_valid_recording_name(file) and from_time < datetime_from_filename(file) <= to_time] cam_files.sort(key=lambda file: file['name']) if cam_files: @@ -382,7 +348,7 @@ def get_recordings_files(cam: Optional[int] = None, async def process_fragments(camera: int, filename: str, fragments: List[Tuple[int, int]]) -> None: - time = filename_to_datetime(filename) + time = datetime_from_filename(filename) rec_dir = get_recordings_path(camera) motion_dir = get_motion_path(camera) @@ -392,8 +358,8 @@ async def process_fragments(camera: int, for fragment in fragments: start, end = fragment - start -= config['motion']['padding'] - end += config['motion']['padding'] + start -= ipcam_config['motion_padding'] + end += ipcam_config['motion_padding'] if start < 0: start = 0 @@ -408,14 +374,14 @@ async def process_fragments(camera: int, start_pos=start, 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)) async def motion_notify_tg(camera: int, filename: str, fragments: List[Tuple[int, int]]): - dt_file = filename_to_datetime(filename) + dt_file = datetime_from_filename(filename) fmt = '%H:%M:%S' 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) for start, end in fragments: - start -= config['motion']['padding'] - end += config['motion']['padding'] + start -= ipcam_config['motion_padding'] + end += ipcam_config['motion_padding'] if start < 0: start = 0 @@ -446,7 +412,7 @@ def _tg_links(link_type: TelegramLinkType, camera: int, file: str) -> str: 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) links.append(f'<a href="{link}">{link_name}</a>') return ' '.join(links) @@ -462,7 +428,7 @@ async def fix_job() -> None: try: 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) if not files: logger.debug(f'fix_job: no files for camera {cam}') @@ -473,7 +439,7 @@ async def fix_job() -> None: 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']) + timestamp = datetime_from_filename(file['name']) if timestamp: db.set_timestamp(cam, TimeFilterType.FIX, timestamp) @@ -482,21 +448,9 @@ async def fix_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: - dt1 = fn2dt(i1) - dt2 = fn2dt(i2) + dt1 = datetime_from_filename(i1) + dt2 = datetime_from_filename(i2) if dt1 < dt2: return -1 @@ -516,18 +470,19 @@ async def cleanup_job() -> None: cleanup_job_running = True 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']): total, used, free = shutil.disk_usage(storage['mountpoint']) free_gb = free // gb - if free_gb < config['cleanup_min_gb']: - # print(f"{storage['mountpoint']}: free={free}, free_gb={free_gb}") + if free_gb < ipcam_config['cleanup_min_gb']: cleaned = 0 files = [] - for cam in storage['cams']: - for _dir in (config['camera'][cam]['recordings_path'], config['camera'][cam]['motion_path']): + for cam in ipcam_config.get_all_cam_names_for_this_server(filter_by_disk=disk_number): + 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(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)) for file in files: @@ -537,7 +492,7 @@ async def cleanup_job() -> None: cleaned += size except OSError as e: logger.exception(e) - if (free + cleaned) // gb >= config['cleanup_min_gb']: + if (free + cleaned) // gb >= ipcam_config['cleanup_min_gb']: break else: 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_re = r'\d{4}-\d{2}-\d{2}-\d{2}\.\d{2}.\d{2}' -db: Optional[IPCamServerDatabase] = None -server: Optional[IPCamWebServer] = None +db: Optional[IpcamServerDatabase] = None +server: Optional[IpcamWebServer] = None logger = logging.getLogger(__name__) @@ -562,7 +517,7 @@ if __name__ == '__main__': parser = ArgumentParser() parser.add_argument('--listen', 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) @@ -570,10 +525,14 @@ if __name__ == '__main__': try: scheduler = AsyncIOScheduler(event_loop=loop) - if config['fix_enabled']: - scheduler.add_job(fix_job, 'interval', seconds=config['fix_interval'], misfire_grace_time=None) - - scheduler.add_job(cleanup_job, 'interval', seconds=config['cleanup_interval'], misfire_grace_time=None) + if ipcam_config['fix_enabled']: + scheduler.add_job(fix_job, 'interval', + seconds=ipcam_config['fix_interval'], + misfire_grace_time=None) + + scheduler.add_job(cleanup_job, 'interval', + seconds=ipcam_config['cleanup_interval'], + misfire_grace_time=None) scheduler.start() except KeyError: pass @@ -581,5 +540,5 @@ if __name__ == '__main__': asyncio.ensure_future(fix_job()) asyncio.ensure_future(cleanup_job()) - server = IPCamWebServer(config.get_addr('server.listen')) + server = IpcamWebServer(Addr.fromstring(arg.listen)) server.run() diff --git a/bin/web_api.py b/bin/web_api.py index e543d22..d221838 100755 --- a/bin/web_api.py +++ b/bin/web_api.py @@ -42,7 +42,6 @@ class WebAPIServer(http.HTTPServer): self.get('/sound_sensors/hits/', self.GET_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.get('/inverter/consumed_energy/', self.GET_consumed_energy) diff --git a/include/py/homekit/audio/amixer.py b/include/py/homekit/audio/amixer.py index 5133c97..8ed754b 100644 --- a/include/py/homekit/audio/amixer.py +++ b/include/py/homekit/audio/amixer.py @@ -1,6 +1,6 @@ import subprocess -from ..config import app_config as config +from ..config import config from threading import Lock from typing import Union, List @@ -10,14 +10,14 @@ _default_step = 5 def has_control(s: str) -> bool: - for control in config['amixer']['controls']: + for control in config.app_config['amixer']['controls']: if control['name'] == s: return True return False 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: return control['caps'] raise KeyError(f'control {s} not found') @@ -25,7 +25,7 @@ def get_caps(s: str) -> List[str]: def get_all() -> list: controls = [] - for control in config['amixer']['controls']: + for control in config.app_config['amixer']['controls']: controls.append({ 'name': control['name'], 'info': get(control['name']), @@ -55,8 +55,8 @@ def nocap(control): def _get_default_step() -> int: - if 'step' in config['amixer']: - return int(config['amixer']['step']) + if 'step' in config.app_config['amixer']: + return int(config.app_config['amixer']['step']) return _default_step @@ -75,7 +75,7 @@ def decr(control, step=None): def call(*args, return_code=False) -> Union[int, str]: with _lock: - result = subprocess.run([config['amixer']['bin'], *args], + result = subprocess.run([config.app_config['amixer']['bin'], *args], stdout=subprocess.PIPE, stderr=subprocess.PIPE) if return_code: diff --git a/include/py/homekit/camera/__init__.py b/include/py/homekit/camera/__init__.py index 626930b..4875031 100644 --- a/include/py/homekit/camera/__init__.py +++ b/include/py/homekit/camera/__init__.py @@ -1 +1,2 @@ -from .types import CameraType
\ No newline at end of file +from .types import CameraType, VideoContainerType, VideoCodecType, CaptureType +from .config import IpcamConfig
\ No newline at end of file diff --git a/include/py/homekit/camera/config.py b/include/py/homekit/camera/config.py index 331e595..c7dbc38 100644 --- a/include/py/homekit/camera/config.py +++ b/include/py/homekit/camera/config.py @@ -1,8 +1,9 @@ +import socket + from ..config import ConfigUnit, LinuxBoardsConfig from typing import Optional from .types import CameraType, VideoContainerType, VideoCodecType - _lbc = LinuxBoardsConfig() @@ -42,7 +43,8 @@ class IpcamConfig(ConfigUnit): 'schema': {'type': 'string', 'check_with': _validate_roi_line} } } - } + }, + 'rtsp_tcp': {'type': 'boolean'} } } }, @@ -55,7 +57,19 @@ class IpcamConfig(ConfigUnit): # TODO FIXME '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 @@ -78,4 +92,39 @@ class IpcamConfig(ConfigUnit): 'empty': False, 'schema': {'type': 'string'} } - }
\ No newline at end of file + } + + 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}' diff --git a/include/py/homekit/camera/types.py b/include/py/homekit/camera/types.py index 0d3a384..c313b58 100644 --- a/include/py/homekit/camera/types.py +++ b/include/py/homekit/camera/types.py @@ -6,6 +6,19 @@ class CameraType(Enum): ALIEXPRESS_NONAME = 'ali' 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): MP4 = 'mp4' @@ -15,3 +28,19 @@ class VideoContainerType(Enum): class VideoCodecType(Enum): H264 = 'h264' 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' diff --git a/include/py/homekit/camera/util.py b/include/py/homekit/camera/util.py index 97f35aa..58c2c70 100644 --- a/include/py/homekit/camera/util.py +++ b/include/py/homekit/camera/util.py @@ -2,13 +2,21 @@ import asyncio import os.path import logging import psutil +import re +from datetime import datetime from typing import List, Tuple 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__) -_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: @@ -26,7 +34,8 @@ def time2seconds(time: str) -> int: async def ffmpeg_recreate(filename: str): 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) args = [_get_ffmpeg_path(), '-nostats', '-loglevel', 'error', '-i', filename, '-c', 'copy', '-y', tempname] @@ -104,4 +113,57 @@ def has_handle(fpath): except Exception: pass - return False
\ No newline at end of file + 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
\ No newline at end of file diff --git a/include/py/homekit/config/_configs.py b/include/py/homekit/config/_configs.py index 1628cba..f88c8ea 100644 --- a/include/py/homekit/config/_configs.py +++ b/include/py/homekit/config/_configs.py @@ -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']) diff --git a/systemd/ipcam_capture@.service b/systemd/ipcam_capture@.service deleted file mode 100644 index e195231..0000000 --- a/systemd/ipcam_capture@.service +++ /dev/null @@ -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 diff --git a/systemd/ipcam_rtsp2hls@.service b/systemd/ipcam_rtsp2hls@.service deleted file mode 100644 index 9ce6cca..0000000 --- a/systemd/ipcam_rtsp2hls@.service +++ /dev/null @@ -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 |