aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEvgeny Zinoviev <me@ch1p.io>2023-06-14 14:06:26 +0300
committerEvgeny Zinoviev <me@ch1p.io>2023-06-19 23:19:35 +0300
commite97f98e5e27a6df3827564cce594f27f18c89267 (patch)
tree0490a13b5df2d9e1fe9d5d6039401b234be99b01
parent5d8e81b6c8fc7abe75188007c6a86bb501a314ad (diff)
wip
-rwxr-xr-xbin/ipcam_capture.py141
-rwxr-xr-xbin/ipcam_capture.sh119
-rwxr-xr-xbin/ipcam_rtsp2hls.sh127
-rwxr-xr-xbin/ipcam_server.py205
-rwxr-xr-xbin/web_api.py1
-rw-r--r--include/py/homekit/audio/amixer.py14
-rw-r--r--include/py/homekit/camera/__init__.py3
-rw-r--r--include/py/homekit/camera/config.py57
-rw-r--r--include/py/homekit/camera/types.py29
-rw-r--r--include/py/homekit/camera/util.py70
-rw-r--r--include/py/homekit/config/_configs.py6
-rw-r--r--systemd/ipcam_capture@.service15
-rw-r--r--systemd/ipcam_rtsp2hls@.service16
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