summaryrefslogtreecommitdiff
path: root/src/home
diff options
context:
space:
mode:
authorEvgeny Zinoviev <me@ch1p.io>2022-06-14 02:44:43 +0300
committerEvgeny Zinoviev <me@ch1p.io>2022-06-14 22:56:46 +0300
commite3d3d6b76010a6dd5c417f017339bec17fb07887 (patch)
tree42cb6194504ae863db2bf7d21ef9e2acd41d0fd2 /src/home
parent600fdf99ffd893857c9cdb9e68140766a963bd17 (diff)
media: refactor sound_node, introduce camera_node
Diffstat (limited to 'src/home')
-rw-r--r--src/home/api/web_api_client.py4
-rw-r--r--src/home/audio/__init__.py0
-rw-r--r--src/home/audio/amixer.py (renamed from src/home/sound/amixer.py)0
-rw-r--r--src/home/camera/__init__.py1
-rw-r--r--src/home/camera/esp32.py140
-rw-r--r--src/home/camera/types.py5
-rw-r--r--src/home/http/__init__.py2
-rw-r--r--src/home/media/__init__.py21
-rw-r--r--src/home/media/__init__.pyi27
-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.py86
-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.py13
-rw-r--r--src/home/sound/__init__.py8
-rw-r--r--src/home/web_api/web_api.py4
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))