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
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)
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=config['cleanup_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()

View File

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

View File

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

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

View File

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