summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorEvgeny Zinoviev <me@ch1p.io>2022-05-17 10:38:27 +0300
committerEvgeny Zinoviev <me@ch1p.io>2022-05-17 23:50:21 +0300
commit6f965e85a633d3b7f7ab049076fb506c91275bea (patch)
tree5b3b7dff726483d86a74c7db4875f16c8ffb3fad /src
parentf1b52a92201e7240519a5fe23cf9a52df013a910 (diff)
initial camera support (only esp32-cam at the moment)
Diffstat (limited to 'src')
-rw-r--r--src/home/bot/wrapper.py3
-rw-r--r--src/home/camera/__init__.py0
-rw-r--r--src/home/camera/esp32.py166
-rw-r--r--src/home/config/config.py15
-rwxr-xr-xsrc/sound_bot.py184
5 files changed, 360 insertions, 8 deletions
diff --git a/src/home/bot/wrapper.py b/src/home/bot/wrapper.py
index 8651e90..8ebde4f 100644
--- a/src/home/bot/wrapper.py
+++ b/src/home/bot/wrapper.py
@@ -271,6 +271,9 @@ class Wrapper:
text = exc2text(text)
self.updater.bot.send_message(chat_id=user_id, text=text, parse_mode='HTML')
+ def send_photo(self, user_id, **kwargs):
+ self.updater.bot.send_photo(chat_id=user_id, **kwargs)
+
def send_audio(self, user_id, **kwargs):
self.updater.bot.send_audio(chat_id=user_id, **kwargs)
diff --git a/src/home/camera/__init__.py b/src/home/camera/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/home/camera/__init__.py
diff --git a/src/home/camera/esp32.py b/src/home/camera/esp32.py
new file mode 100644
index 0000000..ef0ec53
--- /dev/null
+++ b/src/home/camera/esp32.py
@@ -0,0 +1,166 @@
+import logging
+import shutil
+import requests
+import json
+
+from typing import Union, Optional
+from time import sleep
+from enum import Enum
+from ..api.errors import ApiResponseError
+from ..util import Addr
+
+
+class FrameSize(Enum):
+ UXGA_1600x1200 = 13
+ SXGA_1280x1024 = 12
+ HD_1280x720 = 11
+ XGA_1024x768 = 10
+ SVGA_800x600 = 9
+ VGA_640x480 = 8
+ HVGA_480x320 = 7
+ CIF_400x296 = 6
+ QVGA_320x240 = 5
+ N_240x240 = 4
+ HQVGA_240x176 = 3
+ QCIF_176x144 = 2
+ QQVGA_160x120 = 1
+ N_96x96 = 0
+
+
+class WBMode(Enum):
+ AUTO = 0
+ SUNNY = 1
+ CLOUDY = 2
+ OFFICE = 3
+ HOME = 4
+
+
+def _assert_bounds(n: int, min: int, max: int):
+ if not min <= n <= max:
+ raise ValueError(f'value must be between {min} and {max}')
+
+
+class WebClient:
+ 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 setdelay(self, delay: int):
+ self.delay = delay
+
+ def capture(self, save_to: str):
+ self._call('capture', save_to=save_to)
+
+ def getstatus(self):
+ return json.loads(self._call('status'))
+
+ def setflash(self, enable: bool):
+ self._control('flash', int(enable))
+
+ def setresolution(self, fs: FrameSize):
+ self._control('framesize', fs.value)
+
+ def sethmirror(self, enable: bool):
+ self._control('hmirror', int(enable))
+
+ def setvflip(self, enable: bool):
+ self._control('vflip', int(enable))
+
+ def setawb(self, enable: bool):
+ self._control('awb', int(enable))
+
+ def setawbgain(self, enable: bool):
+ self._control('awb_gain', int(enable))
+
+ def setwbmode(self, mode: WBMode):
+ self._control('wb_mode', mode.value)
+
+ def setaecsensor(self, enable: bool):
+ self._control('aec', int(enable))
+
+ def setaecdsp(self, enable: bool):
+ self._control('aec2', int(enable))
+
+ def setagc(self, enable: bool):
+ self._control('agc', int(enable))
+
+ def setagcgain(self, gain: int):
+ _assert_bounds(gain, 1, 31)
+ self._control('agc_gain', gain)
+
+ def setgainceiling(self, gainceiling: int):
+ _assert_bounds(gainceiling, 2, 128)
+ self._control('gainceiling', gainceiling)
+
+ def setbpc(self, enable: bool):
+ self._control('bpc', int(enable))
+
+ def setwpc(self, enable: bool):
+ self._control('wpc', int(enable))
+
+ def setrawgma(self, enable: bool):
+ self._control('raw_gma', int(enable))
+
+ def setlenscorrection(self, enable: bool):
+ self._control('lenc', int(enable))
+
+ def setdcw(self, enable: bool):
+ self._control('dcw', int(enable))
+
+ def setcolorbar(self, enable: bool):
+ self._control('colorbar', int(enable))
+
+ def setquality(self, q: int):
+ _assert_bounds(q, 4, 63)
+ self._control('quality', q)
+
+ def setbrightness(self, brightness: int):
+ _assert_bounds(brightness, -2, -2)
+ self._control('brightness', brightness)
+
+ def setcontrast(self, contrast: int):
+ _assert_bounds(contrast, -2, 2)
+ self._control('contrast', contrast)
+
+ def setsaturation(self, saturation: int):
+ _assert_bounds(saturation, -2, 2)
+ self._control('saturation', saturation)
+
+ def _control(self, var: str, value: Union[int, str]):
+ self._call('control', params={'var': var, 'val': value})
+
+ def _call(self,
+ method: str,
+ params: Optional[dict] = None,
+ save_to: Optional[str] = None):
+
+ if not self.isfirstrequest and self.delay > 0:
+ sleeptime = self.delay / 1000
+ self.logger.debug(f'sleeping for {sleeptime}')
+
+ sleep(sleeptime)
+
+ self.isfirstrequest = False
+
+ url = f'{self.endpoint}/{method}'
+ self.logger.debug(f'calling {url}, params: {params}')
+
+ kwargs = {}
+ if params:
+ kwargs['params'] = params
+ if save_to:
+ kwargs['stream'] = True
+
+ r = requests.get(url, **kwargs)
+ if r.status_code != 200:
+ raise ApiResponseError(status_code=r.status_code)
+
+ if save_to:
+ r.raise_for_status()
+ with open(save_to, 'wb') as f:
+ shutil.copyfileobj(r.raw, f)
+ return True
+
+ return r.text
diff --git a/src/home/config/config.py b/src/home/config/config.py
index 75cfc3a..40aa476 100644
--- a/src/home/config/config.py
+++ b/src/home/config/config.py
@@ -37,20 +37,23 @@ class ConfigStore:
log_default_fmt = False
log_file = None
log_verbose = False
+ no_config = name is False
path = None
if use_cli:
if parser is None:
parser = ArgumentParser()
- parser.add_argument('--config', type=str, required=name is None,
- help='Path to the config in TOML format')
- parser.add_argument('--verbose', action='store_true')
+ if not no_config:
+ parser.add_argument('-c', '--config', type=str, required=name is None,
+ help='Path to the config in TOML format')
+ parser.add_argument('-V', '--verbose', action='store_true')
parser.add_argument('--log-file', type=str)
parser.add_argument('--log-default-fmt', action='store_true')
args = parser.parse_args()
- if args.config:
+ if not no_config and args.config:
path = args.config
+
if args.verbose:
log_verbose = True
if args.log_file:
@@ -58,10 +61,10 @@ class ConfigStore:
if args.log_default_fmt:
log_default_fmt = args.log_default_fmt
- if name and path is None:
+ if not no_config and path is None:
path = _get_config_path(name)
- self.data = toml.load(path)
+ self.data = {} if no_config else toml.load(path)
if 'logging' in self:
if not log_file and 'file' in self['logging']:
diff --git a/src/sound_bot.py b/src/sound_bot.py
index ae54413..4cc08a3 100755
--- a/src/sound_bot.py
+++ b/src/sound_bot.py
@@ -1,6 +1,8 @@
#!/usr/bin/env python3
import logging
import os
+import time
+import tempfile
from enum import Enum
from datetime import datetime, timedelta
@@ -12,6 +14,7 @@ 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
@@ -28,6 +31,7 @@ RenderedContent = tuple[str, Optional[InlineKeyboardMarkup]]
record_client: Optional[RecordClient] = None
bot: Optional[Wrapper] = None
node_client_links: dict[str, SoundNodeClient] = {}
+cam_client_links: dict[str, esp32.WebClient] = {}
def node_client(node: str) -> SoundNodeClient:
@@ -36,10 +40,24 @@ def node_client(node: str) -> SoundNodeClient:
return node_client_links[node]
+def camera_client(cam: str) -> esp32.WebClient:
+ if cam not in node_client_links:
+ cam_client_links[cam] = esp32.WebClient(parse_addr(config['cameras'][cam]['addr']))
+ return cam_client_links[cam]
+
+
def node_exists(node: str) -> bool:
return node in config['nodes']
+def camera_exists(name: str) -> bool:
+ return name in config['cameras']
+
+
+def have_cameras() -> bool:
+ return 'cameras' in config and config['cameras']
+
+
def sound_sensor_exists(node: str) -> bool:
return node in config['sound_sensors']
@@ -299,6 +317,141 @@ class SoundSensorRenderer(Renderer):
return text.encode()
+class CamerasRenderer(Renderer):
+ @classmethod
+ def index(cls, ctx: Context) -> RenderedContent:
+ html = f'<b>{ctx.lang("cameras")}</b>\n\n'
+ html += ctx.lang('select_place')
+ return html, cls.places_markup(ctx, callback_prefix='c0')
+
+ @classmethod
+ def places_markup(cls, ctx: Context, callback_prefix: str) -> InlineKeyboardMarkup:
+ buttons = []
+ for sensor, sensor_label in config['cameras'].items():
+ buttons.append(
+ [InlineKeyboardButton(sensor_label[ctx.user_lang], callback_data=f'{callback_prefix}/{sensor}')])
+ return InlineKeyboardMarkup(buttons)
+
+ @classmethod
+ def camera(cls, ctx: Context) -> RenderedContent:
+ node, = callback_unpack(ctx)
+
+ html = ctx.lang('select_interval')
+ buttons = [
+ [
+ InlineKeyboardButton(ctx.lang('w_flash'), callback_data=f'c1/{node}/1'),
+ InlineKeyboardButton(ctx.lang('wo_flash'), callback_data=f'c1/{node}/0'),
+ ]
+ ]
+ cls.back_button(ctx, buttons, callback_data=f'c0')
+
+ return html, InlineKeyboardMarkup(buttons)
+ #
+ # @classmethod
+ # def record_started(cls, ctx: Context, rid: int) -> RenderedContent:
+ # node, *rest = callback_unpack(ctx)
+ #
+ # place = config['nodes'][node]['label'][ctx.user_lang]
+ #
+ # html = f'<b>{ctx.lang("record_started")}</b> (<i>{place}</i>, id={rid})'
+ # return html, None
+ #
+ # @classmethod
+ # def record_done(cls, info: dict, node: str, uid: int) -> str:
+ # ulang = bot.store.get_user_lang(uid)
+ #
+ # def lang(key, *args):
+ # return bot.lang.get(key, ulang, *args)
+ #
+ # rid = info['id']
+ # fmt = '%d.%m.%y %H:%M:%S'
+ # start_time = datetime.fromtimestamp(int(info['start_time'])).strftime(fmt)
+ # stop_time = datetime.fromtimestamp(int(info['stop_time'])).strftime(fmt)
+ #
+ # place = config['nodes'][node]['label'][ulang]
+ #
+ # html = f'<b>{lang("record_result")}</b> (<i>{place}</i>, id={rid})\n\n'
+ # html += f'<b>{lang("beginning")}</b>: {start_time}\n'
+ # html += f'<b>{lang("end")}</b>: {stop_time}'
+ #
+ # return html
+ #
+ # @classmethod
+ # def record_error(cls, info: dict, node: str, uid: int) -> str:
+ # ulang = bot.store.get_user_lang(uid)
+ #
+ # def lang(key, *args):
+ # return bot.lang.get(key, ulang, *args)
+ #
+ # place = config['nodes'][node]['label'][ulang]
+ # rid = info['id']
+ #
+ # html = f'<b>{lang("record_error")}</b> (<i>{place}</i>, id={rid})'
+ # if 'error' in info:
+ # html += '\n'+str(info['error'])
+ #
+ # return html
+
+
+# cameras handlers
+# ----------------
+
+def cameras(ctx: Context):
+ text, markup = CamerasRenderer.index(ctx)
+ if not ctx.is_callback_context():
+ return ctx.reply(text, markup=markup)
+ else:
+ ctx.answer()
+ return ctx.edit(text, markup=markup)
+
+
+def camera_options(ctx: Context) -> None:
+ cam, = callback_unpack(ctx)
+ if not camera_exists(cam):
+ ctx.answer(ctx.lang('invalid_location'))
+ return
+
+ ctx.answer()
+
+ text, markup = CamerasRenderer.camera(ctx)
+ ctx.edit(text, markup)
+
+
+def camera_capture(ctx: Context) -> None:
+ cam, flash = callback_unpack(ctx)
+ flash = int(flash)
+ if not camera_exists(cam):
+ ctx.answer(ctx.lang('invalid_location'))
+ return
+
+ ctx.answer()
+
+ client = camera_client(cam)
+ 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)
+
+ try:
+ with open(fd.name, 'rb') as f:
+ bot.send_photo(ctx.user_id, photo=f)
+ except TelegramError as exc:
+ logger.exception(exc)
+
+ try:
+ os.unlink(fd.name)
+ except OSError as exc:
+ logger.exception(exc)
+
+
# settings handlers
# -----------------
@@ -626,7 +779,12 @@ class SoundBot(Wrapper):
sound_sensors="Датчики звука",
sound_sensors_info="Здесь можно получить информацию о последних срабатываниях датчиков звука.",
sound_sensors_no_24h_data="За последние 24 часа данных нет.",
- sound_sensors_show_anything="Показать, что есть"
+ sound_sensors_show_anything="Показать, что есть",
+
+ cameras="Камеры",
+ select_option="Выберите опцию",
+ w_flash="Со вспышкой",
+ wo_flash="Без вспышки",
)
self.lang.en(
@@ -672,7 +830,12 @@ class SoundBot(Wrapper):
sound_sensors="Sound sensors",
sound_sensors_info="Here you can get information about last sound sensors hits.",
sound_sensors_no_24h_data="No data for the last 24 hours.",
- sound_sensors_show_anything="Show me at least something"
+ sound_sensors_show_anything="Show me at least something",
+
+ cameras="Cameras",
+ select_option="Select option",
+ w_flash="With flash",
+ wo_flash="Without flash",
)
# ------
@@ -750,6 +913,21 @@ class SoundBot(Wrapper):
# list of specific node's files
# self.add_handler(CallbackQueryHandler(self.wrap(files_list), pattern=r'^g0/.*'))
+ # ------
+ # cameras
+ # ------------
+
+ # list of cameras
+ self.add_handler(MessageHandler(text_filter(self.lang.all('cameras')), self.wrap(cameras)))
+ self.add_handler(CallbackQueryHandler(self.wrap(cameras), pattern=r'^c0$'))
+
+ # list of options (with/without flash etc)
+ self.add_handler(CallbackQueryHandler(self.wrap(camera_options), pattern=r'^c0/.*'))
+
+ # cheese
+ self.add_handler(CallbackQueryHandler(self.wrap(camera_capture), pattern=r'^c1/.*'))
+
+
def markup(self, ctx: Optional[Context]) -> Optional[ReplyKeyboardMarkup]:
buttons = [
[ctx.lang('record'), ctx.lang('settings')],
@@ -760,6 +938,8 @@ class SoundBot(Wrapper):
ctx.lang('guard_enable'), ctx.lang('guard_disable'), ctx.lang('guard_status')
])
buttons.append([ctx.lang('sound_sensors')])
+ if have_cameras():
+ buttons.append([ctx.lang('cameras')])
return ReplyKeyboardMarkup(buttons, one_time_keyboard=False)