commit 4e2b3dcb02a25b4c8598f3d57fddeaa134f8eff2 Author: Davide Depau Date: Fri Sep 15 00:11:13 2023 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a340fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,200 @@ +.idea + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..a3070d3 --- /dev/null +++ b/bot.py @@ -0,0 +1,173 @@ +import asyncio +import os +import traceback +import warnings +from typing import cast, Optional + +import aiohttp +import cv2 +import telegram +from aiohttp import BasicAuth +from pytapo import Tapo +from telegram import Update, Message +from telegram.ext import Updater +from urllib3.exceptions import InsecureRequestWarning + +warnings.filterwarnings("ignore", category=InsecureRequestWarning) + + +class Bot: + def __init__(self): + self.token = os.environ["BOT_TOKEN"] + self.camera_ip = os.environ["CAMERA_IP"] + self.camera_user = os.environ["CAMERA_USER"] + self.camera_password = os.environ["CAMERA_PASSWORD"] + self.chat_id = int(os.environ["CHAT_ID"]) + self.profile_name = os.environ.get("CAMERA_PROFILE_NAME", "board") + self.openhab_url = os.environ["OPENHAB_URL"] + self.openhab_token = os.environ["OPENHAB_TOKEN"] + self.openhab_item = os.environ["OPENHAB_ITEM"] + + self.bot = telegram.Bot(token=self.token) + self.me = None + + self.tapo = Tapo(self.camera_ip, self.camera_user, self.camera_password) + + def _get_presets(self): + presets = self.tapo.getPresets() + return {v: k for k, v in presets.items()} + + async def _get_item_state(self): + url = f"{self.openhab_url}/rest/items/{self.openhab_item}/state" + # use aiohttp instead of requests to avoid blocking + async with aiohttp.ClientSession() as session: + async with session.get(url, auth=BasicAuth(self.openhab_token, "")) as resp: + return await resp.text() + + async def _send_item_command(self, command): + url = f"{self.openhab_url}/rest/items/{self.openhab_item}" + async with aiohttp.ClientSession() as session: + async with session.post( + url, + auth=BasicAuth(self.openhab_token, ""), + data=command, + headers={"Content-Type": "text/plain", "Accept": "*/*"}, + ) as resp: + return await resp.text() + + def _take_photo(self) -> Optional[bytes]: + vcap = cv2.VideoCapture( + f"rtsp://{self.camera_user}:{self.camera_password}@{self.camera_ip}:554/stream1" + ) + ret, frame = vcap.read() + if not ret: + return None + return frame + + async def parse_message(self, msg: Message): + match msg.text: + case "/start": + await self.bot.send_message( + chat_id=msg.chat_id, + text="Hello, I'm a bot that can send you a photo from the camera.", + ) + case "/reposition": + presets = self._get_presets() + if self.profile_name not in presets: + await self.bot.send_message( + chat_id=msg.chat_id, + text=f"Profile '{self.profile_name}' not found", + ) + return + self.tapo.setPreset(presets[self.profile_name]) + await self.bot.send_message( + chat_id=msg.chat_id, + text=f"Repositioned to profile '{self.profile_name}'", + ) + case "/calibrate": + self.tapo.calibrateMotor() + presets = self._get_presets() + if self.profile_name in presets: + self.tapo.setPreset(presets[self.profile_name]) + await self.bot.send_message( + chat_id=msg.chat_id, + text=f"Calibrated and repositioned to profile '{self.profile_name}'", + ) + case "/light_on": + await self._send_item_command("ON") + await self.bot.send_message( + chat_id=msg.chat_id, + text=f"Light turned on", + ) + case "/light_off": + await self._send_item_command("OFF") + await self.bot.send_message( + chat_id=msg.chat_id, + text=f"Light turned off", + ) + case "/light_status": + state = await self._get_item_state() + await self.bot.send_message( + chat_id=msg.chat_id, + text=f"Light is {state}", + ) + case "/photo": + await self.bot.send_chat_action( + chat_id=msg.chat_id, action="upload_photo" + ) + + light_state = await self._get_item_state() + if light_state == "OFF": + print("Turning light on") + await self._send_item_command("ON") + + print("Disabling privacy mode") + self.tapo.setPrivacyMode(False) + await asyncio.sleep(2) + print("Taking photo") + photo = self._take_photo() + print("Enabling privacy mode") + self.tapo.setPrivacyMode(True) + + if light_state == "OFF": + print("Turning light back off") + await self._send_item_command("OFF") + + if photo is None: + await self.bot.send_message( + chat_id=msg.chat_id, + text=f"Error taking photo", + ) + return + # Encode photo to jpeg + photo = cv2.imencode(".jpg", photo)[1].tobytes() + + await self.bot.send_photo( + chat_id=msg.chat_id, + photo=telegram.InputFile(photo, filename="photo.jpg"), + ) + + async def run(self): + async with self.bot: + self.me = await self.bot.get_me() + + updater = Updater(bot=self.bot, update_queue=asyncio.Queue()) + async with updater: + queue = await updater.start_polling(allowed_updates=[Update.MESSAGE]) + + while True: + # noinspection PyBroadException + try: + upd = cast(Update, await queue.get()) + print(upd) + if not upd.message or upd.message.chat_id != self.chat_id: + print("Ignoring message") + continue + await self.parse_message(upd.message) + except Exception: + traceback.print_exc() + + +if __name__ == "__main__": + bot = Bot() + asyncio.run(bot.run()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e928e61 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +pytapo +python-telegram-bot +opencv-python +aiohttp \ No newline at end of file