media: refactor sound_node, introduce camera_node
This commit is contained in:
parent
600fdf99ff
commit
e3d3d6b760
5
doc/camera_node.md
Normal file
5
doc/camera_node.md
Normal file
@ -0,0 +1,5 @@
|
||||
## Configuration
|
||||
|
||||
```
|
||||
|
||||
```
|
@ -30,4 +30,10 @@ label_en = "There"
|
||||
|
||||
[logging]
|
||||
verbose = false
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
apt install python3-matplotlib
|
||||
```
|
@ -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
|
||||
```
|
33
doc/sound_sensor_server.md
Normal file
33
doc/sound_sensor_server.md
Normal file
@ -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
|
||||
```
|
@ -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
|
||||
|
88
src/camera_node.py
Executable file
88
src/camera_node.py
Executable file
@ -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()
|
57
src/esp32_capture.py
Executable file
57
src/esp32_capture.py
Executable file
@ -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
|
@ -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]:
|
||||
|
@ -0,0 +1 @@
|
||||
from .types import CameraType
|
@ -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
|
||||
|
5
src/home/camera/types.py
Normal file
5
src/home/camera/types.py
Normal file
@ -0,0 +1,5 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class CameraType(Enum):
|
||||
ESP32 = 'esp32'
|
@ -1,2 +1,2 @@
|
||||
from .http import serve, ok, routes, HTTPServer
|
||||
from aiohttp.web import FileResponse, Request
|
||||
from aiohttp.web import FileResponse, StreamResponse, Request
|
21
src/home/media/__init__.py
Normal file
21
src/home/media/__init__.py
Normal file
@ -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}")
|
27
src/home/media/__init__.pyi
Normal file
27
src/home/media/__init__.pyi
Normal file
@ -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
|
||||
)
|
@ -1,44 +1,19 @@
|
||||
import requests
|
||||
import logging
|
||||
import shutil
|
||||
import logging
|
||||
|
||||
from ..util import Addr
|
||||
from ..api.errors import ApiResponseError
|
||||
from typing import Optional, Union
|
||||
from .record import RecordFile
|
||||
from .storage import RecordFile
|
||||
from ..util import Addr
|
||||
from ..camera.types import CameraType
|
||||
from ..api.errors import ApiResponseError
|
||||
|
||||
|
||||
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)
|
86
src/home/media/node_server.py
Normal file
86
src/home/media/node_server.py
Normal file
@ -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)
|
@ -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'
|
@ -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] = {}
|
@ -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'
|
||||
|
13
src/home/media/types.py
Normal file
13
src/home/media/types.py
Normal file
@ -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()
|
@ -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
|
@ -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))
|
||||
|
0
src/openwrt_log_analyzer.py
Normal file → Executable file
0
src/openwrt_log_analyzer.py
Normal file → Executable file
@ -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.errors import ApiResponseError
|
||||
from home.sound import SoundNodeClient, RecordClient, RecordFile
|
||||
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 home.api.types import SoundSensorLocation, BotType
|
||||
from home.api.errors import ApiResponseError
|
||||
from home.media import SoundNodeClient, SoundRecordClient, SoundRecordFile, CameraNodeClient
|
||||
from home.soundsensor import SoundSensorServerGuardClient
|
||||
from home.util import parse_addr, chunks, filesize_fmt
|
||||
|
||||
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:
|
||||
|
@ -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)
|
||||
class SoundNodeServer(MediaNodeServer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
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)
|
||||
|
||||
@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}')
|
||||
async def amixer_get_all(self, request: http.Request):
|
||||
controls_info = amixer.get_all()
|
||||
return self.ok(controls_info)
|
||||
|
||||
return _amixer_control_response(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}')
|
||||
|
||||
return _amixer_control_response(control)
|
||||
|
||||
@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_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)
|
||||
f = getattr(amixer, op)
|
||||
f(control)
|
||||
|
||||
return _amixer_control_response(control)
|
||||
return _amixer_control_response(control)
|
||||
|
||||
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}')
|
||||
|
||||
@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}')
|
||||
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
|
||||
|
||||
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
|
||||
f = getattr(amixer, op)
|
||||
f(control, step=get_step())
|
||||
|
||||
f = getattr(amixer, op)
|
||||
f(control, step=get_step())
|
||||
return _amixer_control_response(control)
|
||||
|
||||
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()
|
||||
|
@ -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'])
|
||||
|
||||
record = RecordClient(nodes,
|
||||
error_handler=record_error,
|
||||
finished_handler=record_finished)
|
||||
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))
|
||||
|
||||
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...')
|
||||
|
13
systemd/camera_node.service
Normal file
13
systemd/camera_node.service
Normal file
@ -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
|
13
systemd/camera_node@.service
Normal file
13
systemd/camera_node@.service
Normal file
@ -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
|
0
src/test/test_sensors_plot.py → test/__init__.py
Executable file → Normal file
0
src/test/test_sensors_plot.py → test/__init__.py
Executable file → Normal file
@ -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):
|
@ -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__)), '..')
|
||||
)
|
||||
])
|
||||
|
@ -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__)), '..')
|
||||
)
|
||||
])
|
||||
|
@ -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,
|
@ -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__)), '..')
|
||||
)
|
||||
])
|
||||
|
0
test/test_sensors_plot.py
Executable file
0
test/test_sensors_plot.py
Executable file
@ -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__':
|
@ -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
|
Loading…
x
Reference in New Issue
Block a user