summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--doc/camera_node.md5
-rw-r--r--doc/sensors_bot.md6
-rw-r--r--doc/sound_bot.md86
-rw-r--r--doc/sound_sensor_server.md33
-rw-r--r--requirements.txt1
-rwxr-xr-xsrc/camera_node.py88
-rwxr-xr-xsrc/esp32_capture.py57
-rw-r--r--src/home/api/web_api_client.py4
-rw-r--r--src/home/audio/__init__.py (renamed from src/test/__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__.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
-rwxr-xr-x[-rw-r--r--]src/openwrt_log_analyzer.py0
-rwxr-xr-xsrc/sound_bot.py40
-rwxr-xr-xsrc/sound_node.py192
-rwxr-xr-xsrc/sound_sensor_server.py129
-rw-r--r--systemd/camera_node.service13
-rw-r--r--systemd/camera_node@.service13
-rw-r--r--[-rwxr-xr-x]test/__init__.py (renamed from src/test/test_sensors_plot.py)0
-rwxr-xr-xtest/test.py (renamed from src/test/test.py)0
-rwxr-xr-xtest/test_amixer.py (renamed from src/test/test_amixer.py)4
-rwxr-xr-xtest/test_api.py (renamed from src/test/test_api.py)0
-rwxr-xr-xtest/test_esp32_cam.py (renamed from src/test/test_esp32_cam.py)2
-rwxr-xr-xtest/test_inverter_monitor.py (renamed from src/test/test_inverter_monitor.py)2
-rwxr-xr-xtest/test_record_upload.py (renamed from src/test/test_record_upload.py)10
-rwxr-xr-xtest/test_send_fake_sound_hit.py (renamed from src/test/test_send_fake_sound_hit.py)2
-rwxr-xr-xtest/test_sensors_plot.py0
-rwxr-xr-xtest/test_sound_node_client.py (renamed from src/test/test_sound_node_client.py)4
-rwxr-xr-xtest/test_sound_server_api.py (renamed from src/test/test_sound_server_api.py)2
-rwxr-xr-xtest/test_stopwatch.py (renamed from src/test/test_stopwatch.py)0
42 files changed, 866 insertions, 428 deletions
diff --git a/doc/camera_node.md b/doc/camera_node.md
new file mode 100644
index 0000000..5f2d8d8
--- /dev/null
+++ b/doc/camera_node.md
@@ -0,0 +1,5 @@
+## Configuration
+
+```
+
+``` \ No newline at end of file
diff --git a/doc/sensors_bot.md b/doc/sensors_bot.md
index 9f1c008..c6dba42 100644
--- a/doc/sensors_bot.md
+++ b/doc/sensors_bot.md
@@ -30,4 +30,10 @@ label_en = "There"
[logging]
verbose = false
+```
+
+## Dependencies
+
+```
+apt install python3-matplotlib
``` \ No newline at end of file
diff --git a/doc/sound_bot.md b/doc/sound_bot.md
index f273e7c..93241be 100644
--- a/doc/sound_bot.md
+++ b/doc/sound_bot.md
@@ -2,35 +2,73 @@
## Configuration example
-```toml
-[bot]
-token = "..."
-users = [1, 2]
-manual_record_allowlist = [ 1 ]
-notify_users = [ 1, 2 ]
+```yaml
+bot:
+ token: "..."
+ users: [1, 2]
+ manual_record_allowlist: [ 1 ]
+ notify_users: [ 1, 2 ]
+
+ record_intervals: [15, 30, 60, 180, 300, 600]
+ guard_server: "1.2.3.4:8311"
+
+api:
+ token: "..."
+ host: "..."
-record_intervals = [15, 30, 60, 180, 300, 600]
-guard_server = "1.2.3.4:8311"
+nodes:
+ name1:
+ addr: "1.2.3.4:8313"
+ label:
+ ru: "название 1"
+ en: "name 1"
+ name2:
+ addr: "1.2.3.5:8313"
+ label:
+ ru: "название2"
+ en: "name 2"
-[api]
-token = "..."
-host = "..."
+sound_sensors:
+ name1:
+ ru: "название 1"
+ en: "name 1"
+ name2:
+ ru: "название 2"
+ en: "name 2"
-[nodes]
-name1.addr = '1.2.3.4:8313'
-name1.label = { ru="название 1", en="name 1" }
+cameras:
+ cam1:
+ label:
+ ru: "название 1"
+ en: "name 1"
+ type: esp32
+ addr: "1.2.3.4:80"
+ settings:
+ framesize: 9
+ vflip: true
+ hmirror: true
+ lenc: true
+ wpc: true
+ bpc: false
+ raw_gma: false
+ agc: true
+ gainceiling: 5
+ quality: 10
+ awb_gain: false
+ awb: true
+ aec_dsp: true
+ aec: true
+ flash: true
-name2.addr = '1.2.3.5:8313'
-name2.label = { ru="название2", en="name 2" }
+logging:
+ verbose: false
+ default_fmt: true
-[sound_sensors]
-name1 = { ru="название 1", en="name 1" }
-name2 = { ru="название 2", en="name 2" }
+```
-[cameras]
-name1 = { ru="название 1", en="name 1", type="esp32", addr="1.2.3.4:80", settings = {framesize=9, vflip=true, hmirror=true, lenc=true, wpc=true, bpc=false, raw_gma=false, agc=true, gainceiling=5, quality=10, awb_gain=false, awb=true, aec_dsp=true, aec=true} }
-[logging]
-verbose = false
-default_fmt = true
+## Dependencies
+
+```
+apt install python3-pil
``` \ No newline at end of file
diff --git a/doc/sound_sensor_server.md b/doc/sound_sensor_server.md
new file mode 100644
index 0000000..9543562
--- /dev/null
+++ b/doc/sound_sensor_server.md
@@ -0,0 +1,33 @@
+## Configuration
+
+```
+[server]
+listen = "0.0.0.0:8311"
+guard_control = true
+guard_recording_default = false
+
+[sensor_to_sound_nodes_relations]
+big_house = ['bh1', 'bh2']
+john = ['john']
+
+[sensor_to_camera_nodes_relations]
+big_house = ['bh']
+john = ['john']
+
+[sound_nodes]
+bh1 = { addr = '192.168.1.2:8313', durations = [7, 30] }
+bh2 = { addr = '192.168.1.3:8313', durations = [10, 60] }
+john = { addr = '192.168.1.4:8313', durations = [10, 60] }
+
+[camera_nodes]
+bh = { addr = '192.168.1.2:8314', durations = [7, 30] }
+john = { addr = '192.168.1.4:8314', durations = [10, 60] }
+
+[api]
+token = "..."
+host = "..."
+
+[logging]
+verbose = false
+default_fmt = true
+``` \ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index 9782e86..53c3cd0 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -13,6 +13,7 @@ pytz~=2021.3
PyYAML~=6.0
apscheduler~=3.9.1
psutil~=5.9.1
+aioshutil~=1.1
# following can be installed from debian repositories
# matplotlib~=3.5.0
diff --git a/src/camera_node.py b/src/camera_node.py
new file mode 100755
index 0000000..206361a
--- /dev/null
+++ b/src/camera_node.py
@@ -0,0 +1,88 @@
+#!/usr/bin/env python3
+import asyncio
+import time
+
+from home.config import config
+from home.media import MediaNodeServer, CameraRecordStorage, CameraRecorder
+from home.camera import CameraType, esp32
+from home.util import parse_addr, Addr
+from home import http
+
+
+# Implements HTTP API for a camera.
+# ---------------------------------
+
+class ESP32CameraNodeServer(MediaNodeServer):
+ def __init__(self, web_addr: Addr, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.last_settings_sync = 0
+
+ self.web = esp32.WebClient(web_addr)
+ self.get('/capture/', self.capture)
+
+ async def capture(self, req: http.Request):
+ await self.sync_settings_if_needed()
+
+ try:
+ with_flash = int(req.query['with_flash'])
+ except KeyError:
+ with_flash = 0
+
+ if with_flash:
+ await self.web.setflash(True)
+ await asyncio.sleep(0.2)
+
+ bytes = (await self.web.capture()).read()
+
+ if with_flash:
+ await self.web.setflash(False)
+
+ res = http.StreamResponse()
+ res.content_type = 'image/jpeg'
+ res.content_length = len(bytes)
+
+ await res.prepare(req)
+ await res.write(bytes)
+ await res.write_eof()
+
+ return res
+
+ async def do_record(self, request: http.Request):
+ await self.sync_settings_if_needed()
+
+ # sync settings
+ return super().do_record(request)
+
+ async def sync_settings_if_needed(self):
+ if self.last_settings_sync != 0 and time.time() - self.last_settings_sync < 300:
+ return
+ changed = await self.web.syncsettings(config['camera']['settings'])
+ if changed:
+ self.logger.debug('sync_settings_if_needed: some settings were changed, sleeping for 0.4 sec')
+ await asyncio.sleep(0.4)
+ self.last_settings_sync = time.time()
+
+
+if __name__ == '__main__':
+ config.load('camera_node')
+
+ storage = CameraRecordStorage(config['node']['storage'])
+
+ recorder_kwargs = {}
+ camera_type = CameraType(config['camera']['type'])
+ if camera_type == CameraType.ESP32:
+ recorder_kwargs['stream_addr'] = parse_addr(config['camera']['stream_addr'])
+ else:
+ raise RuntimeError(f'unsupported camera type {camera_type}')
+
+ recorder = CameraRecorder(storage=storage,
+ camera_type=camera_type,
+ **recorder_kwargs)
+ recorder.start_thread()
+
+ server = ESP32CameraNodeServer(
+ recorder=recorder,
+ storage=storage,
+ web_addr=parse_addr(config['camera']['web_addr']),
+ addr=parse_addr(config['node']['listen']))
+ server.run()
diff --git a/src/esp32_capture.py b/src/esp32_capture.py
new file mode 100755
index 0000000..4a9ce10
--- /dev/null
+++ b/src/esp32_capture.py
@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+import asyncio
+import logging
+import os.path
+
+from argparse import ArgumentParser
+from home.camera.esp32 import WebClient
+from home.util import parse_addr, Addr
+from apscheduler.schedulers.asyncio import AsyncIOScheduler
+from datetime import datetime
+from typing import Optional
+
+logger = logging.getLogger(__name__)
+cam: Optional[WebClient] = None
+
+
+class ESP32Capture:
+ def __init__(self, addr: Addr, interval: float, output_directory: str):
+ self.logger = logging.getLogger(self.__class__.__name__)
+ self.client = WebClient(addr)
+ self.output_directory = output_directory
+ self.interval = interval
+
+ self.scheduler = AsyncIOScheduler()
+ self.scheduler.add_job(self.capture, 'interval', seconds=arg.interval)
+ self.scheduler.start()
+
+ async def capture(self):
+ self.logger.debug('capture: start')
+ now = datetime.now()
+ filename = os.path.join(
+ self.output_directory,
+ now.strftime('%Y-%m-%d-%H:%M:%S.%f.jpg')
+ )
+ if not await self.client.capture(filename):
+ self.logger.error('failed to capture')
+ self.logger.debug('capture: done')
+
+
+if __name__ == '__main__':
+ parser = ArgumentParser()
+ parser.add_argument('--addr', type=str, required=True)
+ parser.add_argument('--output-directory', type=str, required=True)
+ parser.add_argument('--interval', type=float, default=0.5)
+ parser.add_argument('--verbose', action='store_true')
+ arg = parser.parse_args()
+
+ if arg.verbose:
+ logging.basicConfig(level=logging.DEBUG)
+
+ loop = asyncio.get_event_loop()
+
+ ESP32Capture(parse_addr(arg.addr), arg.interval, arg.output_directory)
+ try:
+ loop.run_forever()
+ except KeyboardInterrupt:
+ pass
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/test/__init__.py b/src/home/audio/__init__.py
index e69de29..e69de29 100644
--- a/src/test/__init__.py
+++ 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))
diff --git a/src/openwrt_log_analyzer.py b/src/openwrt_log_analyzer.py
index f6d6413..f6d6413 100644..100755
--- a/src/openwrt_log_analyzer.py
+++ b/src/openwrt_log_analyzer.py
diff --git a/src/sound_bot.py b/src/sound_bot.py
index 70d89a8..2503893 100755
--- a/src/sound_bot.py
+++ b/src/sound_bot.py
@@ -8,16 +8,15 @@ from enum import Enum
from datetime import datetime, timedelta
from html import escape
from typing import Optional
+
from home.config import config
from home.bot import Wrapper, Context, text_filter, user_any_name
-from home.api.types import BotType
+from home.api import WebAPIClient
+from home.api.types import SoundSensorLocation, BotType
from home.api.errors import ApiResponseError
-from home.sound import SoundNodeClient, RecordClient, RecordFile
+from home.media import SoundNodeClient, SoundRecordClient, SoundRecordFile, CameraNodeClient
from home.soundsensor import SoundSensorServerGuardClient
-from home.camera import esp32
from home.util import parse_addr, chunks, filesize_fmt
-from home.api import WebAPIClient
-from home.api.types import SoundSensorLocation
from telegram.error import TelegramError
from telegram import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton, User
@@ -30,10 +29,10 @@ from PIL import Image
logger = logging.getLogger(__name__)
RenderedContent = tuple[str, Optional[InlineKeyboardMarkup]]
-record_client: Optional[RecordClient] = None
+record_client: Optional[SoundRecordClient] = None
bot: Optional[Wrapper] = None
node_client_links: dict[str, SoundNodeClient] = {}
-cam_client_links: dict[str, esp32.WebClient] = {}
+cam_client_links: dict[str, CameraNodeClient] = {}
def node_client(node: str) -> SoundNodeClient:
@@ -42,9 +41,9 @@ def node_client(node: str) -> SoundNodeClient:
return node_client_links[node]
-def camera_client(cam: str) -> esp32.WebClient:
+def camera_client(cam: str) -> CameraNodeClient:
if cam not in node_client_links:
- cam_client_links[cam] = esp32.WebClient(parse_addr(config['cameras'][cam]['addr']))
+ cam_client_links[cam] = CameraNodeClient(parse_addr(config['cameras'][cam]['addr']))
return cam_client_links[cam]
@@ -243,7 +242,7 @@ class FilesRenderer(Renderer):
return html, cls.places_markup(ctx, callback_prefix='f0')
@classmethod
- def filelist(cls, ctx: Context, files: list[RecordFile]) -> RenderedContent:
+ def filelist(cls, ctx: Context, files: list[SoundRecordFile]) -> RenderedContent:
node, = callback_unpack(ctx)
html_files = map(lambda file: cls.file(ctx, file, node), files)
@@ -255,7 +254,7 @@ class FilesRenderer(Renderer):
return html, InlineKeyboardMarkup(buttons)
@classmethod
- def file(cls, ctx: Context, file: RecordFile, node: str) -> str:
+ def file(cls, ctx: Context, file: SoundRecordFile, node: str) -> str:
html = ctx.lang('file_line', file.start_humantime, file.stop_humantime, filesize_fmt(file.filesize))
if file.file_id is not None:
html += f'/audio_{node}_{file.file_id}'
@@ -437,23 +436,12 @@ def camera_capture(ctx: Context) -> None:
ctx.answer()
client = camera_client(cam)
- if client.syncsettings(camera_settings(cam)) is True:
- logger.debug('some settings were changed, sleeping for 0.4 sec')
- time.sleep(0.4)
-
- client.setflash(True if flash else False)
- time.sleep(0.2)
-
fd = tempfile.NamedTemporaryFile(delete=False, suffix='.jpg')
fd.close()
client.capture(fd.name)
logger.debug(f'captured photo ({cam}), saved to {fd.name}')
- # disable flash led
- if flash:
- client.setflash(False)
-
camera_config = config['cameras'][cam]
if 'rotate' in camera_config:
im = Image.open(fd.name)
@@ -972,10 +960,10 @@ if __name__ == '__main__':
for nodename, nodecfg in config['nodes'].items():
nodes[nodename] = parse_addr(nodecfg['addr'])
- record_client = RecordClient(nodes,
- error_handler=record_onerror,
- finished_handler=record_onfinished,
- download_on_finish=True)
+ record_client = SoundRecordClient(nodes,
+ error_handler=record_onerror,
+ finished_handler=record_onfinished,
+ download_on_finish=True)
bot = SoundBot()
if 'api' in config:
diff --git a/src/sound_node.py b/src/sound_node.py
index a96a098..7d8ba1a 100755
--- a/src/sound_node.py
+++ b/src/sound_node.py
@@ -3,110 +3,16 @@ import os
from typing import Optional
-from home.config import config
from home.util import parse_addr
-from home.sound import (
- amixer,
- Recorder,
- RecordStatus,
- RecordStorage
-)
+from home.config import config
+from home.audio import amixer
+from home.media import MediaNodeServer, SoundRecordStorage, SoundRecorder
from home import http
-"""
-This script must be run as root as it runs arecord.
-
-This script implements HTTP API for amixer and arecord.
-"""
-
-
-# some global variables
-# ---------------------
-
-recorder: Optional[Recorder]
-routes = http.routes()
-storage: Optional[RecordStorage]
-
-
-# recording methods
-# -----------------
-
-@routes.get('/record/')
-async def do_record(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 = recorder.record(duration)
- return http.ok({'id': record_id})
-
-
-@routes.get('/record/info/{id}/')
-async def record_info(request):
- record_id = int(request.match_info['id'])
- info = recorder.get_info(record_id)
- return http.ok(info.as_dict())
-
-
-@routes.get('/record/forget/{id}/')
-async def record_forget(request):
- record_id = int(request.match_info['id'])
-
- info = recorder.get_info(record_id)
- assert info.status in (RecordStatus.FINISHED, RecordStatus.ERROR), f"can't forget: record status is {info.status}"
-
- recorder.forget(record_id)
- return http.ok()
-
-
-@routes.get('/record/download/{id}/')
-async def record_download(request):
- record_id = int(request.match_info['id'])
-
- info = recorder.get_info(record_id)
- assert info.status == RecordStatus.FINISHED, f"record status is {info.status}"
-
- return http.FileResponse(info.file.path)
-
-
-@routes.get('/storage/list/')
-async def storage_list(request):
- extended = 'extended' in request.query and int(request.query['extended']) == 1
-
- files = storage.getfiles(as_objects=extended)
- if extended:
- files = list(map(lambda file: file.__dict__(), files))
-
- return http.ok({
- 'files': files
- })
-
-
-@routes.get('/storage/delete/')
-async def storage_delete(request):
- file_id = request.query['file_id']
- file = storage.find(file_id)
- if not file:
- raise ValueError(f'file {file} not found')
-
- storage.delete(file)
- return http.ok()
-
-
-@routes.get('/storage/download/')
-async def storage_download(request):
- file_id = request.query['file_id']
- file = storage.find(file_id)
- if not file:
- raise ValueError(f'file {file} not found')
-
- return http.FileResponse(file.path)
-
-
-# ALSA mixer methods
-# ------------------
+# This script must be run as root as it runs arecord.
+# Implements HTTP API for amixer and arecord.
+# -------------------------------------------
def _amixer_control_response(control):
info = amixer.get(control)
@@ -117,57 +23,56 @@ def _amixer_control_response(control):
})
-@routes.get('/amixer/get-all/')
-async def amixer_get_all(request):
- controls_info = amixer.get_all()
- return http.ok(controls_info)
-
-
-@routes.get('/amixer/get/{control}/')
-async def amixer_get(request):
- control = request.match_info['control']
- if not amixer.has_control(control):
- raise ValueError(f'invalid control: {control}')
+class SoundNodeServer(MediaNodeServer):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
- return _amixer_control_response(control)
+ self.get('/amixer/get-all/', self.amixer_get_all)
+ self.get('/amixer/get/{control}/', self.amixer_get)
+ self.get('/amixer/{op:mute|unmute|cap|nocap}/{control}/', self.amixer_set)
+ self.get('/amixer/{op:incr|decr}/{control}/', self.amixer_volume)
+ async def amixer_get_all(self, request: http.Request):
+ controls_info = amixer.get_all()
+ return self.ok(controls_info)
-@routes.get('/amixer/{op:mute|unmute|cap|nocap}/{control}/')
-async def amixer_set(request):
- op = request.match_info['op']
- control = request.match_info['control']
- if not amixer.has_control(control):
- raise ValueError(f'invalid control: {control}')
+ async def amixer_get(self, request: http.Request):
+ control = request.match_info['control']
+ if not amixer.has_control(control):
+ raise ValueError(f'invalid control: {control}')
- f = getattr(amixer, op)
- f(control)
+ return _amixer_control_response(control)
- return _amixer_control_response(control)
+ async def amixer_set(self, request: http.Request):
+ op = request.match_info['op']
+ control = request.match_info['control']
+ if not amixer.has_control(control):
+ raise ValueError(f'invalid control: {control}')
+ f = getattr(amixer, op)
+ f(control)
-@routes.get('/amixer/{op:incr|decr}/{control}/')
-async def amixer_volume(request):
- op = request.match_info['op']
- control = request.match_info['control']
- if not amixer.has_control(control):
- raise ValueError(f'invalid control: {control}')
+ return _amixer_control_response(control)
- def get_step() -> Optional[int]:
- if 'step' in request.query:
- step = int(request.query['step'])
- if not 1 <= step <= 50:
- raise ValueError('invalid step value')
- return step
- return None
+ async def amixer_volume(self, request: http.Request):
+ op = request.match_info['op']
+ control = request.match_info['control']
+ if not amixer.has_control(control):
+ raise ValueError(f'invalid control: {control}')
- f = getattr(amixer, op)
- f(control, step=get_step())
+ def get_step() -> Optional[int]:
+ if 'step' in request.query:
+ step = int(request.query['step'])
+ if not 1 <= step <= 50:
+ raise ValueError('invalid step value')
+ return step
+ return None
- return _amixer_control_response(control)
+ f = getattr(amixer, op)
+ f(control, step=get_step())
+ return _amixer_control_response(control)
-# entry point
-# -----------
if __name__ == '__main__':
if not os.getegid() == 0:
@@ -175,9 +80,12 @@ if __name__ == '__main__':
config.load('sound_node')
- storage = RecordStorage(config['node']['storage'])
+ storage = SoundRecordStorage(config['node']['storage'])
- recorder = Recorder(storage=storage)
+ recorder = SoundRecorder(storage=storage)
recorder.start_thread()
- http.serve(parse_addr(config['node']['listen']), routes)
+ server = SoundNodeServer(recorder=recorder,
+ storage=storage,
+ addr=parse_addr(config['node']['listen']))
+ server.run()
diff --git a/src/sound_sensor_server.py b/src/sound_sensor_server.py
index 20d7f0a..0303d6d 100755
--- a/src/sound_sensor_server.py
+++ b/src/sound_sensor_server.py
@@ -1,31 +1,33 @@
#!/usr/bin/env python3
import logging
import threading
-import os
from time import sleep
from typing import Optional
+from functools import partial
from home.config import config
from home.util import parse_addr
from home.api import WebAPIClient, RequestParams
from home.api.types import SoundSensorLocation
from home.soundsensor import SoundSensorServer, SoundSensorHitHandler
-from home.sound import RecordClient
+from home.media import MediaNodeType, SoundRecordClient, CameraRecordClient, RecordClient
interrupted = False
logger = logging.getLogger(__name__)
server: SoundSensorServer
-def get_related_sound_nodes(sensor_name: str) -> list[str]:
- if sensor_name not in config['sensor_to_sound_nodes_relations']:
+def get_related_nodes(node_type: MediaNodeType,
+ sensor_name: str) -> list[str]:
+ if sensor_name not in config[f'sensor_to_{node_type.name.lower()}_nodes_relations']:
raise ValueError(f'unexpected sensor name {sensor_name}')
- return config['sensor_to_sound_nodes_relations'][sensor_name]
+ return config[f'sensor_to_{node_type.name.lower()}_nodes_relations'][sensor_name]
-def get_sound_node_config(name: str) -> Optional[dict]:
- if name in config['sound_nodes']:
- cfg = config['sound_nodes'][name]
+def get_node_config(node_type: MediaNodeType,
+ name: str) -> Optional[dict]:
+ if name in config[f'{node_type.name.lower()}_nodes']:
+ cfg = config[f'{node_type.name.lower()}_nodes'][name]
if 'min_hits' not in cfg:
cfg['min_hits'] = 1
return cfg
@@ -66,38 +68,43 @@ class HitHandler(SoundSensorHitHandler):
logger.error(f'invalid sensor name: {name}')
return
- try:
- nodes = get_related_sound_nodes(name)
- except ValueError:
- logger.error(f'config for node {name} not found')
- return
-
should_continue = False
- for node in nodes:
- node_config = get_sound_node_config(node)
- if node_config is None:
- logger.error(f'config for node {node} not found')
- continue
- if hits < node_config['min_hits']:
- continue
- should_continue = True
+ for node_type in MediaNodeType:
+ try:
+ nodes = get_related_nodes(node_type, name)
+ except ValueError:
+ logger.error(f'config for {node_type.name.lower()} node {name} not found')
+ return
+
+ for node in nodes:
+ node_config = get_node_config(node_type, node)
+ if node_config is None:
+ logger.error(f'config for {node_type.name.lower()} node {node} not found')
+ continue
+ if hits < node_config['min_hits']:
+ continue
+ should_continue = True
if not should_continue:
return
hc.add(name, hits)
- if server.is_recording_enabled():
+ if not server.is_recording_enabled():
+ return
+ for node_type in MediaNodeType:
try:
+ nodes = get_related_nodes(node_type, name)
for node in nodes:
- node_config = get_sound_node_config(node)
+ node_config = get_node_config(node_type, node)
if node_config is None:
- logger.error(f'node config for {node} not found')
+ logger.error(f'node config for {node_type.name.lower()} node {node} not found')
continue
durations = node_config['durations']
dur = durations[1] if hits > node_config['min_hits'] else durations[0]
- record.record(node, dur*60, {'node': node})
+ record_clients[node_type].record(node, dur*60, {'node': node})
+
except ValueError as exc:
logger.exception(exc)
@@ -112,22 +119,26 @@ def hits_sender():
api: Optional[WebAPIClient] = None
hc: Optional[HitCounter] = None
-record: Optional[RecordClient] = None
+record_clients: dict[MediaNodeType, RecordClient] = {}
# record callbacks
-
# ----------------
-def record_error(info: dict, userdata: dict):
+def record_error(type: MediaNodeType,
+ info: dict,
+ userdata: dict):
node = userdata['node']
- logger.error('recording ' + str(dict) + ' from node ' + node + ' failed')
+ logger.error('recording ' + str(dict) + f' from {type.name.lower()} node ' + node + ' failed')
- record.forget(node, info['id'])
+ record_clients[type].forget(node, info['id'])
-def record_finished(info: dict, fn: str, userdata: dict):
- logger.debug('record finished: ' + str(info))
+def record_finished(type: MediaNodeType,
+ info: dict,
+ fn: str,
+ userdata: dict):
+ logger.debug(f'{type.name.lower()} record finished: ' + str(info))
# audio could have been requested by other user (telegram bot, for example)
# so we shouldn't 'forget' it here
@@ -140,30 +151,8 @@ def record_finished(info: dict, fn: str, userdata: dict):
# --------------------
def api_error_handler(exc, name, req: RequestParams):
- if name == 'upload_recording':
- logger.error('failed to upload recording, exception below')
- logger.exception(exc)
-
- else:
- logger.error(f'api call ({name}, params={req.params}) failed, exception below')
- logger.exception(exc)
-
-
-def api_success_handler(response, name, req: RequestParams):
- if name == 'upload_recording':
- node = req.params['node']
- rid = req.params['record_id']
-
- logger.debug(f'successfully uploaded recording (node={node}, record_id={rid}), api response:' + str(response))
-
- # deleting temp file
- try:
- os.unlink(req.files['file'])
- except OSError as exc:
- logger.error(f'error while deleting temp file:')
- logger.exception(exc)
-
- record.forget(node, rid)
+ logger.error(f'api call ({name}, params={req.params}) failed, exception below')
+ logger.exception(exc)
if __name__ == '__main__':
@@ -171,25 +160,35 @@ if __name__ == '__main__':
hc = HitCounter()
api = WebAPIClient(timeout=(10, 60))
- api.enable_async(error_handler=api_error_handler,
- success_handler=api_success_handler)
+ api.enable_async(error_handler=api_error_handler)
t = threading.Thread(target=hits_sender)
t.daemon = True
t.start()
- nodes = {}
+ sound_nodes = {}
for nodename, nodecfg in config['sound_nodes'].items():
- nodes[nodename] = parse_addr(nodecfg['addr'])
+ sound_nodes[nodename] = parse_addr(nodecfg['addr'])
+
+ camera_nodes = {}
+ for nodename, nodecfg in config['camera_nodes'].items():
+ camera_nodes[nodename] = parse_addr(nodecfg['addr'])
+
+ if sound_nodes:
+ record_clients[MediaNodeType.SOUND] = SoundRecordClient(sound_nodes,
+ error_handler=partial(record_error, MediaNodeType.SOUND),
+ finished_handler=partial(record_finished, MediaNodeType.SOUND))
- record = RecordClient(nodes,
- error_handler=record_error,
- finished_handler=record_finished)
+ if camera_nodes:
+ record_clients[MediaNodeType.CAMERA] = CameraRecordClient(camera_nodes,
+ error_handler=partial(record_error, MediaNodeType.CAMERA),
+ finished_handler=partial(record_finished, MediaNodeType.CAMERA))
try:
server = SoundSensorServer(parse_addr(config['server']['listen']), HitHandler)
server.run()
except KeyboardInterrupt:
interrupted = True
- record.stop()
+ for c in record_clients.values():
+ c.stop()
logging.info('keyboard interrupt, exiting...')
diff --git a/systemd/camera_node.service b/systemd/camera_node.service
new file mode 100644
index 0000000..0de3cc1
--- /dev/null
+++ b/systemd/camera_node.service
@@ -0,0 +1,13 @@
+[Unit]
+Description=HomeKit Camera Node
+After=network-online.target
+
+[Service]
+User=user
+Group=user
+Restart=on-failure
+ExecStart=/home/user/homekit/src/camera_node.py
+WorkingDirectory=/home/user
+
+[Install]
+WantedBy=multi-user.target \ No newline at end of file
diff --git a/systemd/camera_node@.service b/systemd/camera_node@.service
new file mode 100644
index 0000000..414881e
--- /dev/null
+++ b/systemd/camera_node@.service
@@ -0,0 +1,13 @@
+[Unit]
+Description=HomeKit Camera Node
+After=network-online.target
+
+[Service]
+User=user
+Group=user
+Restart=on-failure
+ExecStart=/home/user/homekit/src/camera_node.py --config /home/user/.config/camera_node.%i.yaml
+WorkingDirectory=/home/user
+
+[Install]
+WantedBy=multi-user.target \ No newline at end of file
diff --git a/src/test/test_sensors_plot.py b/test/__init__.py
index e69de29..e69de29 100755..100644
--- a/src/test/test_sensors_plot.py
+++ b/test/__init__.py
diff --git a/src/test/test.py b/test/test.py
index 7ea37e6..7ea37e6 100755
--- a/src/test/test.py
+++ b/test/test.py
diff --git a/src/test/test_amixer.py b/test/test_amixer.py
index ac96881..c8bd546 100755
--- a/src/test/test_amixer.py
+++ b/test/test_amixer.py
@@ -1,12 +1,12 @@
#!/usr/bin/env python3
import sys, os.path
sys.path.extend([
- os.path.realpath(os.path.join(os.path.dirname(os.path.join(__file__)), '..', '..')),
+ os.path.realpath(os.path.join(os.path.dirname(os.path.join(__file__)), '..')),
])
from argparse import ArgumentParser
from src.home.config import config
-from src.home.sound import amixer
+from src.home.audio import amixer
def validate_control(input: str):
diff --git a/src/test/test_api.py b/test/test_api.py
index 959b2b3..959b2b3 100755
--- a/src/test/test_api.py
+++ b/test/test_api.py
diff --git a/src/test/test_esp32_cam.py b/test/test_esp32_cam.py
index 883b6f0..27ce379 100755
--- a/src/test/test_esp32_cam.py
+++ b/test/test_esp32_cam.py
@@ -3,7 +3,7 @@ import sys
import os.path
sys.path.extend([
os.path.realpath(
- os.path.join(os.path.dirname(os.path.join(__file__)), '..', '..')
+ os.path.join(os.path.dirname(os.path.join(__file__)), '..')
)
])
diff --git a/src/test/test_inverter_monitor.py b/test/test_inverter_monitor.py
index d9b63d3..cbf1b82 100755
--- a/src/test/test_inverter_monitor.py
+++ b/test/test_inverter_monitor.py
@@ -8,7 +8,7 @@ import threading
import os.path
sys.path.extend([
os.path.realpath(
- os.path.join(os.path.dirname(os.path.join(__file__)), '..', '..')
+ os.path.join(os.path.dirname(os.path.join(__file__)), '..')
)
])
diff --git a/src/test/test_record_upload.py b/test/test_record_upload.py
index 54ff06f..a0c3faf 100755
--- a/src/test/test_record_upload.py
+++ b/test/test_record_upload.py
@@ -12,7 +12,7 @@ import time
from src.home.api import WebAPIClient, RequestParams
from src.home.config import config
-from src.home.sound import RecordClient
+from src.home.media import SoundRecordClient
from src.home.util import parse_addr
logger = logging.getLogger(__name__)
@@ -69,10 +69,10 @@ if __name__ == '__main__':
nodes = {}
for name, addr in config['nodes'].items():
nodes[name] = parse_addr(addr)
- record = RecordClient(nodes,
- error_handler=record_error,
- finished_handler=record_finished,
- download_on_finish=True)
+ record = SoundRecordClient(nodes,
+ error_handler=record_error,
+ finished_handler=record_finished,
+ download_on_finish=True)
api = WebAPIClient()
api.enable_async(error_handler=api_error_handler,
diff --git a/src/test/test_send_fake_sound_hit.py b/test/test_send_fake_sound_hit.py
index af6b7eb..9660c45 100755
--- a/src/test/test_send_fake_sound_hit.py
+++ b/test/test_send_fake_sound_hit.py
@@ -3,7 +3,7 @@ import sys
import os.path
sys.path.extend([
os.path.realpath(
- os.path.join(os.path.dirname(os.path.join(__file__)), '..', '..')
+ os.path.join(os.path.dirname(os.path.join(__file__)), '..')
)
])
diff --git a/test/test_sensors_plot.py b/test/test_sensors_plot.py
new file mode 100755
index 0000000..e69de29
--- /dev/null
+++ b/test/test_sensors_plot.py
diff --git a/src/test/test_sound_node_client.py b/test/test_sound_node_client.py
index 795165a..16feb78 100755
--- a/src/test/test_sound_node_client.py
+++ b/test/test_sound_node_client.py
@@ -1,11 +1,11 @@
#!/usr/bin/env python3
import sys, os.path
sys.path.extend([
- os.path.realpath(os.path.join(os.path.dirname(os.path.join(__file__)), '..', '..')),
+ os.path.realpath(os.path.join(os.path.dirname(os.path.join(__file__)), '..')),
])
from src.home.api.errors import ApiResponseError
-from src.home.sound import SoundNodeClient
+from src.home.media import SoundNodeClient
if __name__ == '__main__':
diff --git a/src/test/test_sound_server_api.py b/test/test_sound_server_api.py
index 568ea7e..1b4eb8b 100755
--- a/src/test/test_sound_server_api.py
+++ b/test/test_sound_server_api.py
@@ -3,7 +3,7 @@ import sys
import os.path
sys.path.extend([
os.path.realpath(
- os.path.join(os.path.dirname(os.path.join(__file__)), '..', '..')
+ os.path.join(os.path.dirname(os.path.join(__file__)), '..')
)
])
import threading
diff --git a/src/test/test_stopwatch.py b/test/test_stopwatch.py
index 6ff2c0e..6ff2c0e 100755
--- a/src/test/test_stopwatch.py
+++ b/test/test_stopwatch.py