diff options
author | Evgeny Zinoviev <me@ch1p.io> | 2022-06-14 02:44:43 +0300 |
---|---|---|
committer | Evgeny Zinoviev <me@ch1p.io> | 2022-06-14 22:56:46 +0300 |
commit | e3d3d6b76010a6dd5c417f017339bec17fb07887 (patch) | |
tree | 42cb6194504ae863db2bf7d21ef9e2acd41d0fd2 /src/home | |
parent | 600fdf99ffd893857c9cdb9e68140766a963bd17 (diff) |
media: refactor sound_node, introduce camera_node
Diffstat (limited to 'src/home')
-rw-r--r-- | src/home/api/web_api_client.py | 4 | ||||
-rw-r--r-- | src/home/audio/__init__.py | 0 | ||||
-rw-r--r-- | src/home/audio/amixer.py (renamed from src/home/sound/amixer.py) | 0 | ||||
-rw-r--r-- | src/home/camera/__init__.py | 1 | ||||
-rw-r--r-- | src/home/camera/esp32.py | 140 | ||||
-rw-r--r-- | src/home/camera/types.py | 5 | ||||
-rw-r--r-- | src/home/http/__init__.py | 2 | ||||
-rw-r--r-- | src/home/media/__init__.py | 21 | ||||
-rw-r--r-- | src/home/media/__init__.pyi | 27 | ||||
-rw-r--r-- | src/home/media/node_client.py (renamed from src/home/sound/node_client.py) | 75 | ||||
-rw-r--r-- | src/home/media/node_server.py | 86 | ||||
-rw-r--r-- | src/home/media/record.py (renamed from src/home/sound/record.py) | 121 | ||||
-rw-r--r-- | src/home/media/record_client.py (renamed from src/home/sound/record_client.py) | 42 | ||||
-rw-r--r-- | src/home/media/storage.py (renamed from src/home/sound/storage.py) | 56 | ||||
-rw-r--r-- | src/home/media/types.py | 13 | ||||
-rw-r--r-- | src/home/sound/__init__.py | 8 | ||||
-rw-r--r-- | src/home/web_api/web_api.py | 4 |
17 files changed, 447 insertions, 158 deletions
diff --git a/src/home/api/web_api_client.py b/src/home/api/web_api_client.py index e3b0988..34d080c 100644 --- a/src/home/api/web_api_client.py +++ b/src/home/api/web_api_client.py @@ -13,7 +13,7 @@ from .errors import ApiResponseError from .types import * from ..config import config from ..util import stringify -from ..sound import RecordFile, SoundNodeClient +from ..media import RecordFile, MediaNodeClient logger = logging.getLogger(__name__) @@ -103,7 +103,7 @@ class WebAPIClient: def recordings_list(self, extended=False, as_objects=False) -> Union[list[str], list[dict], list[RecordFile]]: files = self._get('recordings/list/', {'extended': int(extended)})['data'] if as_objects: - return SoundNodeClient.record_list_from_serialized(files) + return MediaNodeClient.record_list_from_serialized(files) return files def _process_sound_sensor_hits_data(self, data: list[dict]) -> list[dict]: diff --git a/src/home/audio/__init__.py b/src/home/audio/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/home/audio/__init__.py diff --git a/src/home/sound/amixer.py b/src/home/audio/amixer.py index 0ab2c64..0ab2c64 100644 --- a/src/home/sound/amixer.py +++ b/src/home/audio/amixer.py diff --git a/src/home/camera/__init__.py b/src/home/camera/__init__.py index e69de29..626930b 100644 --- a/src/home/camera/__init__.py +++ b/src/home/camera/__init__.py @@ -0,0 +1 @@ +from .types import CameraType
\ No newline at end of file diff --git a/src/home/camera/esp32.py b/src/home/camera/esp32.py index 246022b..fe6de0e 100644 --- a/src/home/camera/esp32.py +++ b/src/home/camera/esp32.py @@ -1,10 +1,12 @@ import logging -import shutil import requests import json +import asyncio +import aioshutil +from io import BytesIO +from functools import partial from typing import Union, Optional -from time import sleep from enum import Enum from ..api.errors import ApiResponseError from ..util import Addr @@ -41,14 +43,15 @@ def _assert_bounds(n: int, min: int, max: int): class WebClient: - def __init__(self, addr: Addr): + 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 - def syncsettings(self, settings) -> bool: - status = self.getstatus() + async def syncsettings(self, settings) -> bool: + status = await self.getstatus() self.logger.debug(f'syncsettings: status={status}') changed_anything = False @@ -82,7 +85,7 @@ class WebClient: func = getattr(self, f'set{name}') self.logger.debug(f'syncsettings: calling set{name}({value})') - func(value) + await func(value) changed_anything = True except AttributeError as exc: @@ -94,99 +97,106 @@ class WebClient: def setdelay(self, delay: int): self.delay = delay - def capture(self, save_to: str): - self._call('capture', save_to=save_to) + 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) - def getstatus(self): - return json.loads(self._call('status')) + async def getstatus(self): + return json.loads(await self._call('status')) - def setflash(self, enable: bool): - self._control('flash', int(enable)) + async def setflash(self, enable: bool): + await self._control('flash', int(enable)) - def setframesize(self, fs: Union[int, FrameSize]): + async def setframesize(self, fs: Union[int, FrameSize]): if type(fs) is int: fs = FrameSize(fs) - self._control('framesize', fs.value) + await self._control('framesize', fs.value) - def sethmirror(self, enable: bool): - self._control('hmirror', int(enable)) + async def sethmirror(self, enable: bool): + await self._control('hmirror', int(enable)) - def setvflip(self, enable: bool): - self._control('vflip', int(enable)) + async def setvflip(self, enable: bool): + await self._control('vflip', int(enable)) - def setawb(self, enable: bool): - self._control('awb', int(enable)) + async def setawb(self, enable: bool): + await self._control('awb', int(enable)) - def setawbgain(self, enable: bool): - self._control('awb_gain', int(enable)) + async def setawbgain(self, enable: bool): + await self._control('awb_gain', int(enable)) - def setwbmode(self, mode: WBMode): - self._control('wb_mode', mode.value) + async def setwbmode(self, mode: WBMode): + await self._control('wb_mode', mode.value) - def setaecsensor(self, enable: bool): - self._control('aec', int(enable)) + async def setaecsensor(self, enable: bool): + await self._control('aec', int(enable)) - def setaecdsp(self, enable: bool): - self._control('aec2', int(enable)) + async def setaecdsp(self, enable: bool): + await self._control('aec2', int(enable)) - def setagc(self, enable: bool): - self._control('agc', int(enable)) + async def setagc(self, enable: bool): + await self._control('agc', int(enable)) - def setagcgain(self, gain: int): + async def setagcgain(self, gain: int): _assert_bounds(gain, 1, 31) - self._control('agc_gain', gain) + await self._control('agc_gain', gain) - def setgainceiling(self, gainceiling: int): + async def setgainceiling(self, gainceiling: int): _assert_bounds(gainceiling, 2, 128) - self._control('gainceiling', gainceiling) + await self._control('gainceiling', gainceiling) - def setbpc(self, enable: bool): - self._control('bpc', int(enable)) + async def setbpc(self, enable: bool): + await self._control('bpc', int(enable)) - def setwpc(self, enable: bool): - self._control('wpc', int(enable)) + async def setwpc(self, enable: bool): + await self._control('wpc', int(enable)) - def setrawgma(self, enable: bool): - self._control('raw_gma', int(enable)) + async def setrawgma(self, enable: bool): + await self._control('raw_gma', int(enable)) - def setlenscorrection(self, enable: bool): - self._control('lenc', int(enable)) + async def setlenscorrection(self, enable: bool): + await self._control('lenc', int(enable)) - def setdcw(self, enable: bool): - self._control('dcw', int(enable)) + async def setdcw(self, enable: bool): + await self._control('dcw', int(enable)) - def setcolorbar(self, enable: bool): - self._control('colorbar', int(enable)) + async def setcolorbar(self, enable: bool): + await self._control('colorbar', int(enable)) - def setquality(self, q: int): + async def setquality(self, q: int): _assert_bounds(q, 4, 63) - self._control('quality', q) + await self._control('quality', q) - def setbrightness(self, brightness: int): + async def setbrightness(self, brightness: int): _assert_bounds(brightness, -2, -2) - self._control('brightness', brightness) + await self._control('brightness', brightness) - def setcontrast(self, contrast: int): + async def setcontrast(self, contrast: int): _assert_bounds(contrast, -2, 2) - self._control('contrast', contrast) + await self._control('contrast', contrast) - def setsaturation(self, saturation: int): + async def setsaturation(self, saturation: int): _assert_bounds(saturation, -2, 2) - self._control('saturation', saturation) + await self._control('saturation', saturation) - def _control(self, var: str, value: Union[int, str]): - self._call('control', params={'var': var, 'val': value}) + async def _control(self, var: str, value: Union[int, str]): + return await self._call('control', params={'var': var, 'val': value}) - def _call(self, - method: str, - params: Optional[dict] = None, - save_to: Optional[str] = None): + 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}') - sleep(sleeptime) + await asyncio.sleep(sleeptime) self.isfirstrequest = False @@ -199,14 +209,18 @@ class WebClient: if save_to: kwargs['stream'] = True - r = requests.get(url, **kwargs) + 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: - shutil.copyfileobj(r.raw, f) + await aioshutil.copyfileobj(r.raw, f) return True return r.text diff --git a/src/home/camera/types.py b/src/home/camera/types.py new file mode 100644 index 0000000..de59022 --- /dev/null +++ b/src/home/camera/types.py @@ -0,0 +1,5 @@ +from enum import Enum + + +class CameraType(Enum): + ESP32 = 'esp32' diff --git a/src/home/http/__init__.py b/src/home/http/__init__.py index 2597457..963e13c 100644 --- a/src/home/http/__init__.py +++ b/src/home/http/__init__.py @@ -1,2 +1,2 @@ from .http import serve, ok, routes, HTTPServer -from aiohttp.web import FileResponse, Request +from aiohttp.web import FileResponse, StreamResponse, Request
\ No newline at end of file diff --git a/src/home/media/__init__.py b/src/home/media/__init__.py new file mode 100644 index 0000000..e8268cf --- /dev/null +++ b/src/home/media/__init__.py @@ -0,0 +1,21 @@ +import importlib +import itertools + +__map__ = { + 'types': ['MediaNodeType'], + 'record_client': ['SoundRecordClient', 'CameraRecordClient', 'RecordClient'], + 'node_server': ['MediaNodeServer'], + 'node_client': ['SoundNodeClient', 'CameraNodeClient', 'MediaNodeClient'], + 'storage': ['SoundRecordStorage', 'CameraRecordStorage', 'SoundRecordFile', 'CameraRecordFile', 'RecordFile'], + 'record': ['SoundRecorder', 'CameraRecorder'] +} + +__all__ = list(itertools.chain(*__map__.values())) + +def __getattr__(name): + if name in __all__: + for file, names in __map__.items(): + if name in names: + module = importlib.import_module(f'.{file}', __name__) + return getattr(module, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/home/media/__init__.pyi b/src/home/media/__init__.pyi new file mode 100644 index 0000000..0e85cde --- /dev/null +++ b/src/home/media/__init__.pyi @@ -0,0 +1,27 @@ +from .types import ( + MediaNodeType as MediaNodeType +) +from .record_client import ( + SoundRecordClient as SoundRecordClient, + CameraRecordClient as CameraRecordClient, + RecordClient as RecordClient +) +from .node_server import ( + MediaNodeServer as MediaNodeServer +) +from .node_client import ( + SoundNodeClient as SoundNodeClient, + CameraNodeClient as CameraNodeClient, + MediaNodeClient as MediaNodeClient +) +from .storage import ( + SoundRecordStorage as SoundRecordStorage, + CameraRecordStorage as CameraRecordStorage, + SoundRecordFile as SoundRecordFile, + CameraRecordFile as CameraRecordFile, + RecordFile as RecordFile +) +from .record import ( + SoundRecorder as SoundRecorder, + CameraRecorder as CameraRecorder +)
\ No newline at end of file diff --git a/src/home/sound/node_client.py b/src/home/media/node_client.py index 7341208..18f0779 100644 --- a/src/home/sound/node_client.py +++ b/src/home/media/node_client.py @@ -1,44 +1,19 @@ import requests -import logging import shutil +import logging +from typing import Optional, Union +from .storage import RecordFile from ..util import Addr +from ..camera.types import CameraType from ..api.errors import ApiResponseError -from typing import Optional, Union -from .record import RecordFile -class SoundNodeClient: +class MediaNodeClient: def __init__(self, addr: Addr): self.endpoint = f'http://{addr[0]}:{addr[1]}' self.logger = logging.getLogger(self.__class__.__name__) - def amixer_get_all(self): - return self._call('amixer/get-all/') - - def amixer_get(self, control: str): - return self._call(f'amixer/get/{control}/') - - def amixer_incr(self, control: str, step: Optional[int] = None): - params = {'step': step} if step is not None else None - return self._call(f'amixer/incr/{control}/', params=params) - - def amixer_decr(self, control: str, step: Optional[int] = None): - params = {'step': step} if step is not None else None - return self._call(f'amixer/decr/{control}/', params=params) - - def amixer_mute(self, control: str): - return self._call(f'amixer/mute/{control}/') - - def amixer_unmute(self, control: str): - return self._call(f'amixer/unmute/{control}/') - - def amixer_cap(self, control: str): - return self._call(f'amixer/cap/{control}/') - - def amixer_nocap(self, control: str): - return self._call(f'amixer/nocap/{control}/') - def record(self, duration: int): return self._call('record/', params={"duration": duration}) @@ -68,7 +43,7 @@ class SoundNodeClient: kwargs['remote_filesize'] = f['filesize'] else: name = f - item = RecordFile(name, **kwargs) + item = RecordFile.create(name, **kwargs) new_files.append(item) return new_files @@ -82,7 +57,6 @@ class SoundNodeClient: method: str, params: dict = None, save_to: Optional[str] = None): - kwargs = {} if isinstance(params, dict): kwargs['params'] = params @@ -107,3 +81,40 @@ class SoundNodeClient: return True return r.json()['response'] + + +class SoundNodeClient(MediaNodeClient): + def amixer_get_all(self): + return self._call('amixer/get-all/') + + def amixer_get(self, control: str): + return self._call(f'amixer/get/{control}/') + + def amixer_incr(self, control: str, step: Optional[int] = None): + params = {'step': step} if step is not None else None + return self._call(f'amixer/incr/{control}/', params=params) + + def amixer_decr(self, control: str, step: Optional[int] = None): + params = {'step': step} if step is not None else None + return self._call(f'amixer/decr/{control}/', params=params) + + def amixer_mute(self, control: str): + return self._call(f'amixer/mute/{control}/') + + def amixer_unmute(self, control: str): + return self._call(f'amixer/unmute/{control}/') + + def amixer_cap(self, control: str): + return self._call(f'amixer/cap/{control}/') + + def amixer_nocap(self, control: str): + return self._call(f'amixer/nocap/{control}/') + + +class CameraNodeClient(MediaNodeClient): + def capture(self, + save_to: str, + with_flash: bool = False): + return self._call('capture/', + {'with_flash': int(with_flash)}, + save_to=save_to) diff --git a/src/home/media/node_server.py b/src/home/media/node_server.py new file mode 100644 index 0000000..5d0803c --- /dev/null +++ b/src/home/media/node_server.py @@ -0,0 +1,86 @@ +from .. import http +from .record import Recorder +from .types import RecordStatus +from .storage import RecordStorage + + +class MediaNodeServer(http.HTTPServer): + recorder: Recorder + storage: RecordStorage + + def __init__(self, + recorder: Recorder, + storage: RecordStorage, + *args, **kwargs): + super().__init__(*args, **kwargs) + + self.recorder = recorder + self.storage = storage + + self.get('/record/', self.do_record) + self.get('/record/info/{id}/', self.record_info) + self.get('/record/forget/{id}/', self.record_forget) + self.get('/record/download/{id}/', self.record_download) + + self.get('/storage/list/', self.storage_list) + self.get('/storage/delete/', self.storage_delete) + self.get('/storage/download/', self.storage_download) + + async def do_record(self, request: http.Request): + duration = int(request.query['duration']) + max = Recorder.get_max_record_time()*15 + if not 0 < duration <= max: + raise ValueError(f'invalid duration: max duration is {max}') + + record_id = self.recorder.record(duration) + return http.ok({'id': record_id}) + + async def record_info(self, request: http.Request): + record_id = int(request.match_info['id']) + info = self.recorder.get_info(record_id) + return http.ok(info.as_dict()) + + async def record_forget(self, request: http.Request): + record_id = int(request.match_info['id']) + + info = self.recorder.get_info(record_id) + assert info.status in (RecordStatus.FINISHED, RecordStatus.ERROR), f"can't forget: record status is {info.status}" + + self.recorder.forget(record_id) + return http.ok() + + async def record_download(self, request: http.Request): + record_id = int(request.match_info['id']) + + info = self.recorder.get_info(record_id) + assert info.status == RecordStatus.FINISHED, f"record status is {info.status}" + + return http.FileResponse(info.file.path) + + async def storage_list(self, request: http.Request): + extended = 'extended' in request.query and int(request.query['extended']) == 1 + + files = self.storage.getfiles(as_objects=extended) + if extended: + files = list(map(lambda file: file.__dict__(), files)) + + return http.ok({ + 'files': files + }) + + async def storage_delete(self, request: http.Request): + file_id = request.query['file_id'] + file = self.storage.find(file_id) + if not file: + raise ValueError(f'file {file} not found') + + self.storage.delete(file) + return http.ok() + + async def storage_download(self, request): + file_id = request.query['file_id'] + file = self.storage.find(file_id) + if not file: + raise ValueError(f'file {file} not found') + + return http.FileResponse(file.path) diff --git a/src/home/sound/record.py b/src/home/media/record.py index 1ad8827..d3abfbb 100644 --- a/src/home/sound/record.py +++ b/src/home/media/record.py @@ -1,28 +1,22 @@ +import os import threading +import logging import time import subprocess import signal -import os -import logging -from enum import Enum, auto from typing import Optional +from ..util import find_child_processes, Addr from ..config import config -from ..util import find_child_processes from .storage import RecordFile, RecordStorage +from .types import RecordStatus +from ..camera.types import CameraType _history_item_timeout = 7200 _history_cleanup_freq = 3600 -class RecordStatus(Enum): - WAITING = auto() - RECORDING = auto() - FINISHED = auto() - ERROR = auto() - - class RecordHistoryItem: id: int request_time: float @@ -122,21 +116,26 @@ class RecordHistory: class Recording: + RECORDER_PROGRAM = None + start_time: float stop_time: float duration: int record_id: int - arecord_pid: Optional[int] + recorder_program_pid: Optional[int] process: Optional[subprocess.Popen] g_record_id = 1 def __init__(self): + if self.RECORDER_PROGRAM is None: + raise RuntimeError('this is abstract class') + self.start_time = 0 self.stop_time = 0 self.duration = 0 self.process = None - self.arecord_pid = None + self.recorder_program_pid = None self.record_id = Recording.next_id() self.logger = logging.getLogger(self.__class__.__name__) @@ -187,52 +186,51 @@ class Recording: self.start_time = cur self.stop_time = cur + self.duration - arecord = config['arecord']['bin'] - lame = config['lame']['bin'] - b = config['lame']['bitrate'] - - cmd = f'{arecord} -f S16 -r 44100 -t raw 2>/dev/null | {lame} -r -s 44.1 -b {b} -m m - {output} >/dev/null 2>/dev/null' + cmd = self.get_command(output) self.logger.debug(f'start: running `{cmd}`') self.process = subprocess.Popen(cmd, shell=True, stdin=None, stdout=None, stderr=None, close_fds=True) sh_pid = self.process.pid self.logger.debug(f'start: started, pid of shell is {sh_pid}') - arecord_pid = self.find_arecord_pid(sh_pid) - if arecord_pid is not None: - self.arecord_pid = arecord_pid - self.logger.debug(f'start: pid of arecord is {arecord_pid}') + pid = self.find_recorder_program_pid(sh_pid) + if pid is not None: + self.recorder_program_pid = pid + self.logger.debug(f'start: pid of {self.RECORDER_PROGRAM} is {pid}') + + def get_command(self, output: str) -> str: + pass def stop(self): if self.process: - if self.arecord_pid is None: - self.arecord_pid = self.find_arecord_pid(self.process.pid) + if self.recorder_program_pid is None: + self.recorder_program_pid = self.find_recorder_program_pid(self.process.pid) - if self.arecord_pid is not None: - os.kill(self.arecord_pid, signal.SIGINT) + if self.recorder_program_pid is not None: + os.kill(self.recorder_program_pid, signal.SIGINT) timeout = config['node']['process_wait_timeout'] - self.logger.debug(f'stop: sent SIGINT to {self.arecord_pid}. now waiting up to {timeout} seconds...') + self.logger.debug(f'stop: sent SIGINT to {self.recorder_program_pid}. now waiting up to {timeout} seconds...') try: self.process.wait(timeout=timeout) except subprocess.TimeoutExpired: self.logger.warning(f'stop: wait({timeout}): timeout expired, calling terminate()') self.process.terminate() else: - self.logger.warning('stop: pid of arecord is unknown, calling terminate()') + self.logger.warning(f'stop: pid of {self.RECORDER_PROGRAM} is unknown, calling terminate()') self.process.terminate() rc = self.process.returncode self.logger.debug(f'stop: rc={rc}') self.process = None - self.arecord_pid = 0 + self.recorder_program_pid = 0 self.duration = 0 self.start_time = 0 self.stop_time = 0 - def find_arecord_pid(self, sh_pid: int): + def find_recorder_program_pid(self, sh_pid: int): try: children = find_child_processes(sh_pid) except OSError as exc: @@ -240,7 +238,7 @@ class Recording: return None for child in children: - if 'arecord' in child.cmd: + if self.RECORDER_PROGRAM in child.cmd: return child.pid return None @@ -256,6 +254,8 @@ class Recording: class Recorder: + TEMP_NAME = None + interrupted: bool lock: threading.Lock history_lock: threading.Lock @@ -265,9 +265,14 @@ class Recorder: next_history_cleanup_time: float storage: RecordStorage - def __init__(self, storage: RecordStorage): + def __init__(self, + storage: RecordStorage, + recording: Recording): + if self.TEMP_NAME is None: + raise RuntimeError('this is abstract class') + self.storage = storage - self.recording = Recording() + self.recording = recording self.interrupted = False self.lock = threading.Lock() self.history_lock = threading.Lock() @@ -282,7 +287,7 @@ class Recorder: t.start() def loop(self) -> None: - tempname = os.path.join(self.storage.root, 'temp.mp3') + tempname = os.path.join(self.storage.root, self.TEMP_NAME) while not self.interrupted: cur = time.time() @@ -398,3 +403,51 @@ class Recorder: def get_max_record_time() -> int: return config['node']['record_max_time'] + +class SoundRecorder(Recorder): + TEMP_NAME = 'temp.mp3' + + def __init__(self, *args, **kwargs): + super().__init__(recording=SoundRecording(), + *args, **kwargs) + + +class CameraRecorder(Recorder): + TEMP_NAME = 'temp.mp4' + + def __init__(self, + camera_type: CameraType, + *args, **kwargs): + if camera_type == CameraType.ESP32: + recording = ESP32CameraRecording(stream_addr=kwargs['stream_addr']) + del kwargs['stream_addr'] + else: + raise RuntimeError(f'unsupported camera type {camera_type}') + + super().__init__(recording=recording, + *args, **kwargs) + + +class SoundRecording(Recording): + RECORDER_PROGRAM = 'arecord' + + def get_command(self, output: str) -> str: + arecord = config['arecord']['bin'] + lame = config['lame']['bin'] + b = config['lame']['bitrate'] + + return f'{arecord} -f S16 -r 44100 -t raw 2>/dev/null | {lame} -r -s 44.1 -b {b} -m m - {output} >/dev/null 2>/dev/null' + + +class ESP32CameraRecording(Recording): + RECORDER_PROGRAM = 'esp32_capture.py' + + stream_addr: Addr + + def __init__(self, stream_addr: Addr): + super().__init__() + self.stream_addr = stream_addr + + def get_command(self, output: str) -> str: + bin = config['esp32_capture']['bin'] + return f'{bin} --addr {self.stream_addr[0]}:{self.stream_addr[1]} --output-directory {output} >/dev/null 2>/dev/null'
\ No newline at end of file diff --git a/src/home/sound/record_client.py b/src/home/media/record_client.py index 2744a8c..f264155 100644 --- a/src/home/sound/record_client.py +++ b/src/home/media/record_client.py @@ -5,15 +5,17 @@ import os.path from tempfile import gettempdir from .record import RecordStatus -from .node_client import SoundNodeClient +from .node_client import SoundNodeClient, MediaNodeClient, CameraNodeClient from ..util import Addr from typing import Optional, Callable class RecordClient: + DOWNLOAD_EXTENSION = None + interrupted: bool logger: logging.Logger - clients: dict[str, SoundNodeClient] + clients: dict[str, MediaNodeClient] awaiting: dict[str, dict[int, Optional[dict]]] error_handler: Optional[Callable] finished_handler: Optional[Callable] @@ -24,20 +26,21 @@ class RecordClient: error_handler: Optional[Callable] = None, finished_handler: Optional[Callable] = None, download_on_finish=False): + if self.DOWNLOAD_EXTENSION is None: + raise RuntimeError('this is abstract class') + self.interrupted = False self.logger = logging.getLogger(self.__class__.__name__) self.clients = {} self.awaiting = {} - self.download_on_finish = download_on_finish + self.download_on_finish = download_on_finish self.error_handler = error_handler self.finished_handler = finished_handler self.awaiting_lock = threading.Lock() - for node, addr in nodes.items(): - self.clients[node] = SoundNodeClient(addr) - self.awaiting[node] = {} + self.make_clients(nodes) try: t = threading.Thread(target=self.loop) @@ -47,13 +50,14 @@ class RecordClient: self.stop() self.logger.exception(exc) + def make_clients(self, nodes: dict[str, Addr]): + pass + def stop(self): self.interrupted = True def loop(self): while not self.interrupted: - # self.logger.debug('loop: tick') - for node in self.awaiting.keys(): with self.awaiting_lock: record_ids = list(self.awaiting[node].keys()) @@ -125,7 +129,7 @@ class RecordClient: self.awaiting[node][record_id] = userdata def download(self, node: str, record_id: int, fileid: str): - dst = os.path.join(gettempdir(), f'{node}_{fileid}.mp3') + dst = os.path.join(gettempdir(), f'{node}_{fileid}.{self.DOWNLOAD_EXTENSION}') cl = self.getclient(node) cl.record_download(record_id, dst) return dst @@ -140,3 +144,23 @@ class RecordClient: def _report_error(self, *args): if self.error_handler: self.error_handler(*args) + + +class SoundRecordClient(RecordClient): + DOWNLOAD_EXTENSION = 'mp3' + # clients: dict[str, SoundNodeClient] + + def make_clients(self, nodes: dict[str, Addr]): + for node, addr in nodes.items(): + self.clients[node] = SoundNodeClient(addr) + self.awaiting[node] = {} + + +class CameraRecordClient(RecordClient): + DOWNLOAD_EXTENSION = 'mp4' + # clients: dict[str, CameraNodeClient] + + def make_clients(self, nodes: dict[str, Addr]): + for node, addr in nodes.items(): + self.clients[node] = CameraNodeClient(addr) + self.awaiting[node] = {}
\ No newline at end of file diff --git a/src/home/sound/storage.py b/src/home/media/storage.py index c61f6f6..880b899 100644 --- a/src/home/sound/storage.py +++ b/src/home/media/storage.py @@ -10,7 +10,12 @@ from ..util import strgen logger = logging.getLogger(__name__) +# record file +# ----------- + class RecordFile: + EXTENSION = None + start_time: Optional[datetime] stop_time: Optional[datetime] record_id: Optional[int] @@ -23,14 +28,26 @@ class RecordFile: human_date_dmt = '%d.%m.%y' human_time_fmt = '%H:%M:%S' + @staticmethod + def create(filename: str, *args, **kwargs): + if filename.endswith(f'.{SoundRecordFile.EXTENSION}'): + return SoundRecordFile(filename, *args, **kwargs) + elif filename.endswith(f'.{CameraRecordFile.EXTENSION}'): + return CameraRecordFile(filename, *args, **kwargs) + else: + raise RuntimeError(f'unsupported file extension: {filename}') + def __init__(self, filename: str, remote=False, remote_filesize=None, storage_root='/'): + if self.EXTENSION is None: + raise RuntimeError('this is abstract class') + self.name = filename self.storage_root = storage_root self.remote = remote self.remote_filesize = remote_filesize - m = re.match(r'^(\d{6}-\d{6})_(\d{6}-\d{6})_id(\d+)(_\w+)?\.mp3$', filename) + m = re.match(r'^(\d{6}-\d{6})_(\d{6}-\d{6})_id(\d+)(_\w+)?\.'+self.EXTENSION+'$', filename) if m: self.start_time = datetime.strptime(m.group(1), RecordStorage.time_fmt) self.stop_time = datetime.strptime(m.group(2), RecordStorage.time_fmt) @@ -99,24 +116,40 @@ class RecordFile: } +class SoundRecordFile(RecordFile): + EXTENSION = 'mp3' + + +class CameraRecordFile(RecordFile): + EXTENSION = 'mp4' + + +# record storage +# -------------- + class RecordStorage: + EXTENSION = None + time_fmt = '%d%m%y-%H%M%S' def __init__(self, root: str): + if self.EXTENSION is None: + raise RuntimeError('this is abstract class') + self.root = root def getfiles(self, as_objects=False) -> Union[list[str], list[RecordFile]]: files = [] for name in os.listdir(self.root): path = os.path.join(self.root, name) - if os.path.isfile(path) and name.endswith('.mp3'): - files.append(name if not as_objects else RecordFile(name, storage_root=self.root)) + if os.path.isfile(path) and name.endswith(f'.{self.EXTENSION}'): + files.append(name if not as_objects else RecordFile.create(name, storage_root=self.root)) return files def find(self, file_id: str) -> Optional[RecordFile]: for name in os.listdir(self.root): - if os.path.isfile(os.path.join(self.root, name)) and name.endswith('.mp3'): - item = RecordFile(name, storage_root=self.root) + if os.path.isfile(os.path.join(self.root, name)) and name.endswith(f'.{self.EXTENSION}'): + item = RecordFile.create(name, storage_root=self.root) if item.file_id == file_id: return item return None @@ -148,8 +181,17 @@ class RecordStorage: dst_fn = f'{start_time_s}_{stop_time_s}_id{record_id}' if os.path.exists(os.path.join(self.root, dst_fn)): dst_fn += strgen(4) - dst_fn += '.mp3' + dst_fn += f'.{self.EXTENSION}' dst_path = os.path.join(self.root, dst_fn) shutil.move(fn, dst_path) - return RecordFile(dst_fn, storage_root=self.root) + return RecordFile.create(dst_fn, storage_root=self.root) + + +class SoundRecordStorage(RecordStorage): + EXTENSION = 'mp3' + + +class CameraRecordStorage(RecordStorage): + EXTENSION = 'mp4' + diff --git a/src/home/media/types.py b/src/home/media/types.py new file mode 100644 index 0000000..acbc291 --- /dev/null +++ b/src/home/media/types.py @@ -0,0 +1,13 @@ +from enum import Enum, auto + + +class MediaNodeType(Enum): + SOUND = auto() + CAMERA = auto() + + +class RecordStatus(Enum): + WAITING = auto() + RECORDING = auto() + FINISHED = auto() + ERROR = auto() diff --git a/src/home/sound/__init__.py b/src/home/sound/__init__.py deleted file mode 100644 index 43ddaff..0000000 --- a/src/home/sound/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from .node_client import SoundNodeClient -from .record import ( - RecordStatus, - RecordingNotFoundError, - Recorder, -) -from .storage import RecordStorage, RecordFile -from .record_client import RecordClient diff --git a/src/home/web_api/web_api.py b/src/home/web_api/web_api.py index c75c031..6b8c54e 100644 --- a/src/home/web_api/web_api.py +++ b/src/home/web_api/web_api.py @@ -12,7 +12,7 @@ from ..config import config, is_development_mode from ..database import BotsDatabase, SensorsDatabase from ..util import stringify, format_tb from ..api.types import BotType, TemperatureSensorLocation, SoundSensorLocation -from ..sound import RecordStorage +from ..media import SoundRecordStorage db: Optional[BotsDatabase] = None sensors_db: Optional[SensorsDatabase] = None @@ -136,7 +136,7 @@ def recordings_list(): if not os.path.isdir(root): raise ValueError(f'invalid node {node}: no such directory') - storage = RecordStorage(root) + storage = SoundRecordStorage(root) files = storage.getfiles(as_objects=extended) if extended: files = list(map(lambda file: file.__dict__(), files)) |