media: refactor sound_node, introduce camera_node

This commit is contained in:
Evgeny Zinoviev 2022-06-14 02:44:43 +03:00
parent 600fdf99ff
commit e3d3d6b760
42 changed files with 869 additions and 431 deletions

5
doc/camera_node.md Normal file
View File

@ -0,0 +1,5 @@
## Configuration
```
```

View File

@ -30,4 +30,10 @@ label_en = "There"
[logging]
verbose = false
```
## Dependencies
```
apt install python3-matplotlib
```

View File

@ -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
```

View 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
```

View File

@ -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
View 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
View 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

View File

@ -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]:

View File

@ -0,0 +1 @@
from .types import CameraType

View File

@ -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
View File

@ -0,0 +1,5 @@
from enum import Enum
class CameraType(Enum):
ESP32 = 'esp32'

View File

@ -1,2 +1,2 @@
from .http import serve, ok, routes, HTTPServer
from aiohttp.web import FileResponse, Request
from aiohttp.web import FileResponse, StreamResponse, Request

View 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}")

View 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
)

View File

@ -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)

View 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)

View File

@ -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'

View File

@ -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] = {}

View File

@ -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
View 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()

View File

@ -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

View File

@ -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
View File

View 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:

View File

@ -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()

View File

@ -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...')

View 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

View 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
View File

View 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):

View File

@ -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__)), '..')
)
])

View 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__)), '..')
)
])

View 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,

View File

@ -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
View File

View 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__':

View File

@ -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