summaryrefslogtreecommitdiff
path: root/include/py/homekit/camera
diff options
context:
space:
mode:
Diffstat (limited to 'include/py/homekit/camera')
-rw-r--r--include/py/homekit/camera/__init__.py2
-rw-r--r--include/py/homekit/camera/config.py130
-rw-r--r--include/py/homekit/camera/esp32.py226
-rw-r--r--include/py/homekit/camera/types.py46
-rw-r--r--include/py/homekit/camera/util.py169
5 files changed, 573 insertions, 0 deletions
diff --git a/include/py/homekit/camera/__init__.py b/include/py/homekit/camera/__init__.py
new file mode 100644
index 0000000..4875031
--- /dev/null
+++ b/include/py/homekit/camera/__init__.py
@@ -0,0 +1,2 @@
+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
new file mode 100644
index 0000000..c7dbc38
--- /dev/null
+++ b/include/py/homekit/camera/config.py
@@ -0,0 +1,130 @@
+import socket
+
+from ..config import ConfigUnit, LinuxBoardsConfig
+from typing import Optional
+from .types import CameraType, VideoContainerType, VideoCodecType
+
+_lbc = LinuxBoardsConfig()
+
+
+def _validate_roi_line(field, value, error) -> bool:
+ p = value.split(' ')
+ if len(p) != 4:
+ error(field, f'{field}: must contain four coordinates separated by space')
+ for n in p:
+ if not n.isnumeric():
+ error(field, f'{field}: invalid coordinates (not a number)')
+ return True
+
+
+class IpcamConfig(ConfigUnit):
+ NAME = 'ipcam'
+
+ @classmethod
+ def schema(cls) -> Optional[dict]:
+ return {
+ 'cams': {
+ 'type': 'dict',
+ 'keysrules': {'type': ['string', 'integer']},
+ 'valuesrules': {
+ 'type': 'dict',
+ 'schema': {
+ 'type': {'type': 'string', 'allowed': [t.value for t in CameraType], 'required': True},
+ 'codec': {'type': 'string', 'allowed': [t.value for t in VideoCodecType], 'required': True},
+ 'container': {'type': 'string', 'allowed': [t.value for t in VideoContainerType], 'required': True},
+ 'server': {'type': 'string', 'allowed': list(_lbc.get().keys()), 'required': True},
+ 'disk': {'type': 'integer', 'required': True},
+ 'motion': {
+ 'type': 'dict',
+ 'schema': {
+ 'threshold': {'type': ['float', 'integer']},
+ 'roi': {
+ 'type': 'list',
+ 'schema': {'type': 'string', 'check_with': _validate_roi_line}
+ }
+ }
+ },
+ 'rtsp_tcp': {'type': 'boolean'}
+ }
+ }
+ },
+ 'motion_padding': {'type': 'integer', 'required': True},
+ 'motion_telegram': {'type': 'boolean', 'required': True},
+ 'fix_interval': {'type': 'integer', 'required': True},
+ 'fix_enabled': {'type': 'boolean', 'required': True},
+ 'cleanup_min_gb': {'type': 'integer', 'required': True},
+ 'cleanup_interval': {'type': 'integer', 'required': True},
+
+ # TODO FIXME
+ 'fragment_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
+ def custom_validator(data):
+ for n, cam in data['cams'].items():
+ linux_box = _lbc[cam['server']]
+ if 'ext_hdd' not in linux_box:
+ raise ValueError(f'cam-{n}: linux box {cam["server"]} must have ext_hdd defined')
+ disk = cam['disk']-1
+ if disk < 0 or disk >= len(linux_box['ext_hdd']):
+ raise ValueError(f'cam-{n}: invalid disk index for linux box {cam["server"]}')
+
+ @classmethod
+ def _url_templates_schema(cls) -> dict:
+ return {
+ 'type': 'list',
+ 'empty': False,
+ 'schema': {
+ 'type': 'list',
+ '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}'
diff --git a/include/py/homekit/camera/esp32.py b/include/py/homekit/camera/esp32.py
new file mode 100644
index 0000000..fe6de0e
--- /dev/null
+++ b/include/py/homekit/camera/esp32.py
@@ -0,0 +1,226 @@
+import logging
+import requests
+import json
+import asyncio
+import aioshutil
+
+from io import BytesIO
+from functools import partial
+from typing import Union, Optional
+from enum import Enum
+from ..api.errors import ApiResponseError
+from ..util import Addr
+
+
+class FrameSize(Enum):
+ UXGA_1600x1200 = 13
+ SXGA_1280x1024 = 12
+ HD_1280x720 = 11
+ XGA_1024x768 = 10
+ SVGA_800x600 = 9
+ VGA_640x480 = 8
+ HVGA_480x320 = 7
+ CIF_400x296 = 6
+ QVGA_320x240 = 5
+ N_240x240 = 4
+ HQVGA_240x176 = 3
+ QCIF_176x144 = 2
+ QQVGA_160x120 = 1
+ N_96x96 = 0
+
+
+class WBMode(Enum):
+ AUTO = 0
+ SUNNY = 1
+ CLOUDY = 2
+ OFFICE = 3
+ HOME = 4
+
+
+def _assert_bounds(n: int, min: int, max: int):
+ if not min <= n <= max:
+ raise ValueError(f'value must be between {min} and {max}')
+
+
+class WebClient:
+ def __init__(self,
+ addr: Addr):
+ self.endpoint = f'http://{addr[0]}:{addr[1]}'
+ self.logger = logging.getLogger(self.__class__.__name__)
+ self.delay = 0
+ self.isfirstrequest = True
+
+ async def syncsettings(self, settings) -> bool:
+ status = await self.getstatus()
+ self.logger.debug(f'syncsettings: status={status}')
+
+ changed_anything = False
+
+ for name, value in settings.items():
+ server_name = name
+ if name == 'aec_dsp':
+ server_name = 'aec2'
+
+ if server_name not in status:
+ # legacy compatibility
+ if server_name != 'vflip':
+ self.logger.warning(f'syncsettings: field `{server_name}` not found in camera status')
+ continue
+
+ try:
+ # server returns 0 or 1 for bool values
+ if type(value) is bool:
+ value = int(value)
+
+ if status[server_name] == value:
+ continue
+ except KeyError as exc:
+ if name != 'vflip':
+ self.logger.error(exc)
+
+ try:
+ # fix for cases like when field is called raw_gma, but method is setrawgma()
+ name = name.replace('_', '')
+
+ func = getattr(self, f'set{name}')
+ self.logger.debug(f'syncsettings: calling set{name}({value})')
+
+ await func(value)
+
+ changed_anything = True
+ except AttributeError as exc:
+ self.logger.exception(exc)
+ self.logger.error(f'syncsettings: method set{name}() not found')
+
+ return changed_anything
+
+ def setdelay(self, delay: int):
+ self.delay = delay
+
+ async def capture(self, output: Optional[str] = None) -> Union[BytesIO, bool]:
+ kw = {}
+ if output:
+ kw['save_to'] = output
+ else:
+ kw['as_bytes'] = True
+ return await self._call('capture', **kw)
+
+ async def getstatus(self):
+ return json.loads(await self._call('status'))
+
+ async def setflash(self, enable: bool):
+ await self._control('flash', int(enable))
+
+ async def setframesize(self, fs: Union[int, FrameSize]):
+ if type(fs) is int:
+ fs = FrameSize(fs)
+ await self._control('framesize', fs.value)
+
+ async def sethmirror(self, enable: bool):
+ await self._control('hmirror', int(enable))
+
+ async def setvflip(self, enable: bool):
+ await self._control('vflip', int(enable))
+
+ async def setawb(self, enable: bool):
+ await self._control('awb', int(enable))
+
+ async def setawbgain(self, enable: bool):
+ await self._control('awb_gain', int(enable))
+
+ async def setwbmode(self, mode: WBMode):
+ await self._control('wb_mode', mode.value)
+
+ async def setaecsensor(self, enable: bool):
+ await self._control('aec', int(enable))
+
+ async def setaecdsp(self, enable: bool):
+ await self._control('aec2', int(enable))
+
+ async def setagc(self, enable: bool):
+ await self._control('agc', int(enable))
+
+ async def setagcgain(self, gain: int):
+ _assert_bounds(gain, 1, 31)
+ await self._control('agc_gain', gain)
+
+ async def setgainceiling(self, gainceiling: int):
+ _assert_bounds(gainceiling, 2, 128)
+ await self._control('gainceiling', gainceiling)
+
+ async def setbpc(self, enable: bool):
+ await self._control('bpc', int(enable))
+
+ async def setwpc(self, enable: bool):
+ await self._control('wpc', int(enable))
+
+ async def setrawgma(self, enable: bool):
+ await self._control('raw_gma', int(enable))
+
+ async def setlenscorrection(self, enable: bool):
+ await self._control('lenc', int(enable))
+
+ async def setdcw(self, enable: bool):
+ await self._control('dcw', int(enable))
+
+ async def setcolorbar(self, enable: bool):
+ await self._control('colorbar', int(enable))
+
+ async def setquality(self, q: int):
+ _assert_bounds(q, 4, 63)
+ await self._control('quality', q)
+
+ async def setbrightness(self, brightness: int):
+ _assert_bounds(brightness, -2, -2)
+ await self._control('brightness', brightness)
+
+ async def setcontrast(self, contrast: int):
+ _assert_bounds(contrast, -2, 2)
+ await self._control('contrast', contrast)
+
+ async def setsaturation(self, saturation: int):
+ _assert_bounds(saturation, -2, 2)
+ await self._control('saturation', saturation)
+
+ async def _control(self, var: str, value: Union[int, str]):
+ return await self._call('control', params={'var': var, 'val': value})
+
+ async def _call(self,
+ method: str,
+ params: Optional[dict] = None,
+ save_to: Optional[str] = None,
+ as_bytes=False) -> Union[str, bool, BytesIO]:
+ loop = asyncio.get_event_loop()
+
+ if not self.isfirstrequest and self.delay > 0:
+ sleeptime = self.delay / 1000
+ self.logger.debug(f'sleeping for {sleeptime}')
+
+ await asyncio.sleep(sleeptime)
+
+ self.isfirstrequest = False
+
+ url = f'{self.endpoint}/{method}'
+ self.logger.debug(f'calling {url}, params: {params}')
+
+ kwargs = {}
+ if params:
+ kwargs['params'] = params
+ if save_to:
+ kwargs['stream'] = True
+
+ r = await loop.run_in_executor(None,
+ partial(requests.get, url, **kwargs))
+ if r.status_code != 200:
+ raise ApiResponseError(status_code=r.status_code)
+
+ if as_bytes:
+ return BytesIO(r.content)
+
+ if save_to:
+ r.raise_for_status()
+ with open(save_to, 'wb') as f:
+ await aioshutil.copyfileobj(r.raw, f)
+ return True
+
+ return r.text
diff --git a/include/py/homekit/camera/types.py b/include/py/homekit/camera/types.py
new file mode 100644
index 0000000..c313b58
--- /dev/null
+++ b/include/py/homekit/camera/types.py
@@ -0,0 +1,46 @@
+from enum import Enum
+
+
+class CameraType(Enum):
+ ESP32 = 'esp32'
+ 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'
+ MOV = 'mov'
+
+
+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
new file mode 100644
index 0000000..58c2c70
--- /dev/null
+++ b/include/py/homekit/camera/util.py
@@ -0,0 +1,169 @@
+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, LinuxBoardsConfig
+from .config import IpcamConfig
+from .types import VideoContainerType
+
+_logger = logging.getLogger(__name__)
+_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:
+ return 'ffmpeg' if 'ffmpeg' not in config else config['ffmpeg']['path']
+
+
+def time2seconds(time: str) -> int:
+ time, frac = time.split('.')
+ frac = int(frac)
+
+ h, m, s = [int(i) for i in time.split(':')]
+
+ return round(s + m*60 + h*3600 + frac/1000)
+
+
+async def ffmpeg_recreate(filename: str):
+ filedir = os.path.dirname(filename)
+ _, 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]
+ proc = await asyncio.create_subprocess_exec(*args,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE)
+ stdout, stderr = await proc.communicate()
+ if proc.returncode != 0:
+ _logger.error(f'fix_timestamps({filename}): ffmpeg returned {proc.returncode}, stderr: {stderr.decode().strip()}')
+
+ if os.path.isfile(tempname):
+ os.unlink(filename)
+ os.rename(tempname, filename)
+ os.utime(filename, (mtime, mtime))
+ _logger.info(f'fix_timestamps({filename}): OK')
+ else:
+ _logger.error(f'fix_timestamps({filename}): temp file \'{tempname}\' does not exists, fix failed')
+
+
+async def ffmpeg_cut(input: str,
+ output: str,
+ start_pos: int,
+ duration: int):
+ args = [_get_ffmpeg_path(), '-nostats', '-loglevel', 'error', '-i', input,
+ '-ss', str(start_pos), '-t', str(duration),
+ '-c', 'copy', '-y', output]
+ proc = await asyncio.create_subprocess_exec(*args,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE)
+ stdout, stderr = await proc.communicate()
+ if proc.returncode != 0:
+ _logger.error(f'ffmpeg_cut({input}, start_pos={start_pos}, duration={duration}): ffmpeg returned {proc.returncode}, stderr: {stderr.decode().strip()}')
+ else:
+ _logger.info(f'ffmpeg_cut({input}): OK')
+
+
+def dvr_scan_timecodes(timecodes: str) -> List[Tuple[int, int]]:
+ tc_backup = timecodes
+
+ timecodes = timecodes.split(',')
+ if len(timecodes) % 2 != 0:
+ raise DVRScanInvalidTimecodes(f'invalid number of timecodes. input: {tc_backup}')
+
+ timecodes = list(map(time2seconds, timecodes))
+ timecodes = list(chunks(timecodes, 2))
+
+ # sort out invalid fragments (dvr-scan returns them sometimes, idk why...)
+ timecodes = list(filter(lambda f: f[0] < f[1], timecodes))
+ if not timecodes:
+ raise DVRScanInvalidTimecodes(f'no valid timecodes. input: {tc_backup}')
+
+ # https://stackoverflow.com/a/43600953
+ timecodes.sort(key=lambda interval: interval[0])
+ merged = [timecodes[0]]
+ for current in timecodes:
+ previous = merged[-1]
+ if current[0] <= previous[1]:
+ previous[1] = max(previous[1], current[1])
+ else:
+ merged.append(current)
+
+ return merged
+
+
+class DVRScanInvalidTimecodes(Exception):
+ pass
+
+
+def has_handle(fpath):
+ for proc in psutil.process_iter():
+ try:
+ for item in proc.open_files():
+ if fpath == item.path:
+ return True
+ except Exception:
+ pass
+
+ return False
+
+
+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