diff --git a/conf/docker-compose.yaml b/conf/docker-compose.yaml index 51758c4..a6355d8 100644 --- a/conf/docker-compose.yaml +++ b/conf/docker-compose.yaml @@ -106,7 +106,12 @@ services: timeout: 30s environment: IPGEOLOCATION_API_KEY: ${IPGEOLOCATION_API_KEY} + FRIGATE_CONFIG_FILE: /frigate_config/config.yaml volumes: + - type: volume + source: config + target: /frigate_config + read_only: true - type: bind source: /etc/localtime target: /etc/localtime diff --git a/notify/src/frigate_event_notifier.py b/notify/src/frigate_event_notifier.py index f635492..62b7f75 100644 --- a/notify/src/frigate_event_notifier.py +++ b/notify/src/frigate_event_notifier.py @@ -14,10 +14,10 @@ class FrigateEventNotifier: self.mqtt_client.on_connect = self._on_connect self.mqtt_client.on_message = self._on_message - frigate_api_response = requests.get('http://frigate:5000/api/config') + frigate_api_response = requests.get("http://frigate:5000/api/config") frigate_api_response.raise_for_status() frigate_config = json.loads(frigate_api_response.content) - self.camera_zones = {camera_name: {required_zone: {object_label for object_label in camera_config['zones'][required_zone]['objects']} for required_zone in camera_config['record']['events']['required_zones']} for camera_name, camera_config in frigate_config['cameras'].items()} + self.camera_zones = {camera_name: {required_zone: {object_label for object_label in camera_config["zones"][required_zone]["objects"]} for required_zone in camera_config["record"]["events"]["required_zones"]} for camera_name, camera_config in frigate_config["cameras"].items()} self.quiet_period = quiet_period self.last_notification_time = {} @@ -27,55 +27,55 @@ class FrigateEventNotifier: if now - self.last_notification_time.get(camera, dt.datetime.min) >= dt.timedelta(seconds=self.quiet_period): # Quiet period has passed since the last notification for this camera self.last_notification_time[camera] = now - camera_location = ' '.join(word.capitalize() for word in camera.split('_')) + camera_location = " ".join(word.capitalize() for word in camera.split("_")) - ntfy_api_response = requests.post('https://ntfy.homelab.net', json={ - 'topic': 'frigate_notifications', - 'title': 'Frigate', - 'message': f'{object_label.capitalize()} at {camera_location} ({score:.0%})', - 'priority': priority, - 'click': f'https://frigate.homelab.net/cameras/{camera}', - 'icon': 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/frigate.png', - 'attach': f'https://frigate.homelab.net/api/events/{event_id}/thumbnail.jpg?format=android', - 'actions': [ + ntfy_api_response = requests.post("https://ntfy.homelab.net", json={ + "topic": "frigate_notifications", + "title": "Frigate", + "message": f"{object_label.capitalize()} at {camera_location} ({score:.0%})", + "priority": priority, + "click": f"https://frigate.homelab.net/cameras/{camera}", + "icon": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/frigate.png", + "attach": f"https://frigate.homelab.net/api/events/{event_id}/thumbnail.jpg?format=android", + "actions": [ { - 'action': 'http', - 'label': 'Disable (30m)', - 'url': f'https://frigate.homelab.net/webcontrol/camera/{camera}/detect', - 'method': 'POST', - 'headers': { - 'Content-Type': 'application/json' + "action": "http", + "label": "Disable (30m)", + "url": f"https://frigate.homelab.net/webcontrol/camera/{camera}/detection", + "method": "POST", + "headers": { + "Content-Type": "application/json" }, - 'body': json.dumps({ - 'value': False, - 'duration': 30 + "body": json.dumps({ + "detection": False, + "duration": 30 }), - 'clear': True + "clear": True } ] }) ntfy_api_response.raise_for_status() - ntfy_api_response = requests.post('https://ntfy.homelab.net', json={ - 'topic': 'frigate_notifications_dad', - 'title': 'Frigate', - 'message': f'{object_label.capitalize()} at {camera_location} ({score:.0%})', - 'priority': priority, - 'click': f'https://frigate.homelab.net/cameras/{camera}', - 'icon': 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/frigate.png', - 'attach': f'https://frigate.homelab.net/api/events/{event_id}/thumbnail.jpg?format=android', - 'actions': [ + ntfy_api_response = requests.post("https://ntfy.homelab.net", json={ + "topic": "frigate_notifications_dad", + "title": "Frigate", + "message": f"{object_label.capitalize()} at {camera_location} ({score:.0%})", + "priority": priority, + "click": f"https://frigate.homelab.net/cameras/{camera}", + "icon": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/frigate.png", + "attach": f"https://frigate.homelab.net/api/events/{event_id}/thumbnail.jpg?format=android", + "actions": [ { - 'action': 'http', - 'label': 'DBL (30m)', - 'url': f'https://frigate.homelab.net/webcontrol/camera/{camera}/detect', - 'method': 'POST', - 'headers': { - 'Content-Type': 'application/json' + "action": "http", + "label": "DBL (30m)", + "url": f"https://frigate.homelab.net/webcontrol/camera/{camera}/detection", + "method": "POST", + "headers": { + "Content-Type": "application/json" }, - 'body': json.dumps({ - 'value': False, - 'duration': 30 + "body": json.dumps({ + "detection": False, + "duration": 30 }) } ] @@ -83,36 +83,36 @@ class FrigateEventNotifier: ntfy_api_response.raise_for_status() def start(self): - self.mqtt_client.connect(host='mqtt', port=1883) + self.mqtt_client.connect(host="mqtt", port=1883) self.mqtt_client.loop_forever() def _on_connect(self, client, userdata, flags, rc): - print(f'Connected with return code {rc}') - client.subscribe('frigate/events') + print(f"Connected with return code {rc}") + client.subscribe("frigate/events") def _on_message(self, client, userdata, message): payload = json.loads(message.payload.decode()) - camera = payload['after']['camera'] - object_label = payload['after']['label'] + camera = payload["after"]["camera"] + object_label = payload["after"]["label"] if not self.camera_zones[camera]: # No required zones, send notification on receipt of new event - if payload['type'] == 'new': - event_id = payload['after']['id'] - score = payload['after']['top_score'] + if payload["type"] == "new": + event_id = payload["after"]["id"] + score = payload["after"]["top_score"] self.send_notification(event_id, camera, object_label, score) else: - new_zones = set(payload['after']['entered_zones']) - set(payload['before']['entered_zones']) + new_zones = set(payload["after"]["entered_zones"]) - set(payload["before"]["entered_zones"]) for zone in new_zones: if zone in self.camera_zones[camera] and (not self.camera_zones[camera][zone] or object_label in self.camera_zones[camera][zone]): - event_id = payload['after']['id'] - score = payload['after']['top_score'] + event_id = payload["after"]["id"] + score = payload["after"]["top_score"] self.send_notification(event_id, camera, object_label, score) break -if __name__ == '__main__': - frigate_event_notifier = FrigateEventNotifier(os.environ.get('MQTT_USERNAME'), os.environ.get('MQTT_PASSWORD')) +if __name__ == "__main__": + frigate_event_notifier = FrigateEventNotifier(os.environ.get("MQTT_USERNAME"), os.environ.get("MQTT_PASSWORD")) frigate_event_notifier.start() diff --git a/uptime/src/logging.yaml b/uptime/src/configs/logging.yaml similarity index 94% rename from uptime/src/logging.yaml rename to uptime/src/configs/logging.yaml index d608d1e..8e55f8b 100644 --- a/uptime/src/logging.yaml +++ b/uptime/src/configs/logging.yaml @@ -2,7 +2,7 @@ version: 1 disable_existing_loggers: false formatters: default: - format: '[{asctime}.{msecs:03.0f}][{levelname}] {name}:{lineno}:{funcName} - {message}' + format: '[{asctime}.{msecs:03.0f}][{levelname}] {name}:{lineno} - {message}' style: '{' datefmt: '%Y-%m-%d %H:%M:%S' handlers: diff --git a/uptime/src/uptime/__init__.py b/uptime/src/uptime/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/uptime/src/uptime/__main__.py b/uptime/src/uptime/__main__.py index 15926f7..85064bf 100644 --- a/uptime/src/uptime/__main__.py +++ b/uptime/src/uptime/__main__.py @@ -17,9 +17,9 @@ def entrypoint() -> None: try: asyncio.run(main()) except KeyboardInterrupt: - logger.info('Received Ctrl+C, exiting...') + logger.info("Received Ctrl+C, exiting...") -if __name__ == '__main__': +if __name__ == "__main__": entrypoint() diff --git a/uptime/src/uptime/frigate_config.py b/uptime/src/uptime/frigate_config.py index 9704f16..cdb7195 100644 --- a/uptime/src/uptime/frigate_config.py +++ b/uptime/src/uptime/frigate_config.py @@ -2,8 +2,7 @@ import re import json import logging from contextlib import AsyncExitStack -from types import TracebackType -from typing import Any, ClassVar, Pattern, Self +from typing import ClassVar, Pattern import aiohttp @@ -11,58 +10,47 @@ logger = logging.getLogger(__name__) class FrigateConfig: - _URL_ADDRESS_REGEX: ClassVar[Pattern[str]] = re.compile('(^|(?<=://)|(?<=@))[a-z0-9.\\-]+(:[0-9]+)?($|(?=/))') - _WYZE_CAMERAS: ClassVar[dict[str, str]] = {'back_yard_cam': '192.168.0.202:554'} + _URL_ADDRESS_REGEX: ClassVar[Pattern[str]] = re.compile("(^|(?<=://)|(?<=@))[a-z0-9.\\-]+(:[0-9]+)?($|(?=/))") + _WYZE_CAMERAS: ClassVar[dict[str, str]] = {"back_yard_cam": "192.168.0.202:554"} - def __init__(self, frigate_base_url: str = 'http://frigate:5000') -> None: - self._frigate_config_url = f'{frigate_base_url}/api/config' + def __init__(self, frigate_base_url: str = "http://frigate:5000") -> None: + self._frigate_config_url = f"{frigate_base_url}/api/config" self._config = {} - self._aiohttp_session: aiohttp.ClientSession - self._async_exit_stack: AsyncExitStack - - async def __aenter__(self) -> Self: - async with AsyncExitStack() as async_exit_stack: - self._aiohttp_session = await async_exit_stack.enter_async_context(aiohttp.ClientSession(raise_for_status=True)) - self._async_exit_stack = async_exit_stack.pop_all() - - await self.refresh() - return self - - async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None) -> None: - await self._async_exit_stack.aclose() async def refresh(self) -> None: - logger.debug('Fetching Frigate config...') + logger.debug("Fetching Frigate configuration") try: - async with self._aiohttp_session.get(self._frigate_config_url) as response: + async with AsyncExitStack() as exit_stack: + aiohttp_session = await exit_stack.enter_async_context(aiohttp.ClientSession(raise_for_status=True)) + response = await exit_stack.enter_async_context(aiohttp_session.get(self._frigate_config_url)) self._config = json.loads(await response.read()) - logger.debug('Finished fetching Frigate config') except Exception as e: if self._config: - logger.warning('Failed to fetch Frigate config, falling back to previous value', exc_info=e) + logger.error("Failed to fetch Frigate config, falling back to previous value", exc_info=e) else: raise + logger.debug("Fetched Frigate configuration") @property def active_cameras(self) -> list[str]: - if 'cameras' not in self._config: - raise ValueError('Configuration not yet fetched from Frigate') + if "cameras" not in self._config: + raise ValueError("Configuration not yet fetched from Frigate") - return [camera for camera in self._config['cameras'] if self._config['cameras'][camera].get('enabled', True)] + return [camera for camera in self._config["cameras"] if self._config["cameras"][camera].get("enabled", True)] @property def active_camera_addresses(self) -> dict[str, str]: active_cameras = self.active_cameras - return {camera: self._get_address_from_url(self._config['cameras'][camera]['ffmpeg']['inputs'][0]['path']) for camera in active_cameras} + return {camera: self._get_address_from_url(self._config["cameras"][camera]["ffmpeg"]["inputs"][0]["path"]) for camera in active_cameras} @classmethod def _get_address_from_url(cls, url: str) -> str: match = cls._URL_ADDRESS_REGEX.search(url.lower()) if match is None: - raise ValueError(f'Failed to retrieve address from {url=}') + raise ValueError(f"Failed to retrieve address from {url=}") # Handle case of wyze-bridge and hardcode cameras - if match.group().startswith('wyze-bridge'): - wyze_camera = url.lower().rsplit('/', 1)[-1].replace('-', '_') + if match.group().startswith("wyze-bridge"): + wyze_camera = url.lower().rsplit("/", 1)[-1].replace("-", "_") return cls._WYZE_CAMERAS[wyze_camera] return match.group() diff --git a/uptime/src/uptime/monitor.py b/uptime/src/uptime/monitor.py index 059596c..b166cd2 100644 --- a/uptime/src/uptime/monitor.py +++ b/uptime/src/uptime/monitor.py @@ -17,11 +17,11 @@ class CameraMonitor: self._wait_time = wait_time self._consecutive_down_threshold = consecutive_down_threshold self._camera_downtime = Counter() - self._frigate_config: FrigateConfig - self._ntfy_notifier: NtfyNotifier + self._frigate_config = FrigateConfig() + self._ntfy_notifier = NtfyNotifier async def _on_camera_up(self, camera: str) -> None: - logger.info(f'Camera {camera} is back online') + logger.info(f"Camera {camera} is back online") await self._ntfy_notifier.send_notification(camera, True) async def _on_camera_down(self, camera: str) -> None: @@ -29,7 +29,7 @@ class CameraMonitor: if camera not in self._frigate_config.active_cameras: return - logger.info(f'Camera {camera} is down') + logger.info(f"Camera {camera} is down") await self._ntfy_notifier.send_notification(camera, False) async def _on_camera_ping(self, camera: str, success: bool) -> None: @@ -43,16 +43,13 @@ class CameraMonitor: self._camera_downtime[camera] += 1 async def run(self) -> None: - async with AsyncExitStack() as async_exit_stack: - self._frigate_config = await async_exit_stack.enter_async_context(FrigateConfig()) - self._ntfy_notifier = await async_exit_stack.enter_async_context(NtfyNotifier()) + await self._frigate_config.refresh() + while True: + camera_ips = {camera: address.split(":", 1)[0] for camera, address in self._frigate_config.active_camera_addresses.items()} + ping_results = await ip_ping_all(*camera_ips.values()) + for i, camera in enumerate(camera_ips): + await self._on_camera_ping(camera, ping_results[i]) - while True: - camera_ips = {camera: address.split(':', 1)[0] for camera, address in self._frigate_config.active_camera_addresses.items()} - ping_results = await ip_ping_all(*camera_ips.values()) - for i, camera in enumerate(camera_ips): - await self._on_camera_ping(camera, ping_results[i]) - - logger.debug(f'Sleeping for {self._wait_time} seconds...') - await asyncio.sleep(self._wait_time) + logger.debug(f"Sleeping for {self._wait_time} seconds...") + await asyncio.sleep(self._wait_time) diff --git a/uptime/src/uptime/notify.py b/uptime/src/uptime/notify.py index 880be03..a90ca7e 100644 --- a/uptime/src/uptime/notify.py +++ b/uptime/src/uptime/notify.py @@ -1,7 +1,4 @@ import logging -from contextlib import AsyncExitStack -from types import TracebackType -from typing import Self import aiohttp @@ -9,29 +6,19 @@ logger = logging.getLogger(__name__) class NtfyNotifier: - def __init__(self, ntfy_url: str = 'https://ntfy.homelab.net') -> None: + def __init__(self, ntfy_url: str = "https://ntfy.homelab.net") -> None: self._ntfy_url = ntfy_url - self._aiohttp_session: aiohttp.ClientSession - self._async_exit_stack: AsyncExitStack - - async def __aenter__(self) -> Self: - async with AsyncExitStack() as async_exit_stack: - self._aiohttp_session = await async_exit_stack.enter_async_context(aiohttp.ClientSession(raise_for_status=True)) - self._async_exit_stack = async_exit_stack.pop_all() - return self - - async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None) -> None: - await self._async_exit_stack.aclose() async def send_notification(self, camera: str, status: bool) -> None: - logger.debug(f'Sending notification for {camera=}...') - message = f'{camera} is back online' if status else f'{camera} is offline' - await self._aiohttp_session.post(self._ntfy_url, ssl=False, json={ - 'topic': 'frigate_camera_uptime', - 'title': 'Frigate', - 'message': message, - 'priority': 3, - 'click': f'https://frigate.homelab.net/cameras/{camera}', - 'icon': 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/frigate.png', - }) - logger.debug(f'Sent notification for {camera=}') + async with aiohttp.ClientSession(raise_for_status=True) as aiohttp_session: + logger.debug(f"Sending notification for {camera=}...") + message = f"{camera} is back online" if status else f"{camera} is offline" + await aiohttp_session.post(self._ntfy_url, ssl=False, json={ + "topic": "frigate_camera_uptime", + "title": "Frigate", + "message": message, + "priority": 3, + "click": f"https://frigate.homelab.net/cameras/{camera}", + "icon": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/frigate.png", + }) + logger.debug(f"Sent notification for {camera=}") diff --git a/uptime/src/uptime/ping.py b/uptime/src/uptime/ping.py index 8615f2b..88db902 100644 --- a/uptime/src/uptime/ping.py +++ b/uptime/src/uptime/ping.py @@ -5,10 +5,10 @@ logger = logging.getLogger(__name__) async def ip_ping(host: str) -> bool: - logger.debug(f'Pinging {host}...') - process = await asyncio.create_subprocess_exec('ping', '-w', '3', '-c', '1', host, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL) + logger.debug(f"Pinging {host}") + process = await asyncio.create_subprocess_exec("ping", "-w", "3", "-c", "1", host, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL) return_code = await process.wait() - logger.debug(f'Finished pinging {host}') + logger.debug(f"Finished pinging {host}") return return_code == 0 async def ip_ping_all(*hosts: str) -> list[bool]: diff --git a/uptime/src/uptime/utils/logging.py b/uptime/src/uptime/utils/logging.py index 51cb462..5b6f2a2 100644 --- a/uptime/src/uptime/utils/logging.py +++ b/uptime/src/uptime/utils/logging.py @@ -1,10 +1,11 @@ import logging +import logging.config import yaml def configure_logging() -> None: - with open('logging.yaml', 'r') as logging_yaml: + with open("configs/logging.yaml", "r") as logging_yaml: logging_config = yaml.safe_load(logging_yaml) logging.config.dictConfig(logging_config) diff --git a/webcontrol/Dockerfile b/webcontrol/Dockerfile index 754f161..02a44f1 100644 --- a/webcontrol/Dockerfile +++ b/webcontrol/Dockerfile @@ -1,7 +1,8 @@ FROM python:3.11 WORKDIR /code -ENTRYPOINT ["python3", "server.py"] +ENTRYPOINT ["uvicorn", "webcontrol:api", "--host", "0.0.0.0", "--port", "80"] +CMD ["--log-level", "warning"] RUN pip3 install --upgrade pip diff --git a/webcontrol/requirements.txt b/webcontrol/requirements.txt index 946054f..b38d166 100644 --- a/webcontrol/requirements.txt +++ b/webcontrol/requirements.txt @@ -1,3 +1,6 @@ -flask==2.3.2 +PyYAML==6.0.1 +pydantic==2.6.4 +fastapi==0.110.0 +uvicorn[standard]==0.29.0 +aiohttp==3.8.6 paho-mqtt==1.6.1 -requests==2.31.0 diff --git a/webcontrol/src/configs/logging.yaml b/webcontrol/src/configs/logging.yaml new file mode 100644 index 0000000..8e55f8b --- /dev/null +++ b/webcontrol/src/configs/logging.yaml @@ -0,0 +1,23 @@ +version: 1 +disable_existing_loggers: false +formatters: + default: + format: '[{asctime}.{msecs:03.0f}][{levelname}] {name}:{lineno} - {message}' + style: '{' + datefmt: '%Y-%m-%d %H:%M:%S' +handlers: + stdout: + class: logging.StreamHandler + level: INFO + formatter: default + stream: ext://sys.stdout + stderr: + class: logging.StreamHandler + level: ERROR + formatter: default + stream: ext://sys.stderr +root: + level: DEBUG + handlers: + - stdout + - stderr diff --git a/webcontrol/src/detection.py b/webcontrol/src/detection.py deleted file mode 100644 index 997a23c..0000000 --- a/webcontrol/src/detection.py +++ /dev/null @@ -1,92 +0,0 @@ -import os -import sys -import json -import time -import traceback -from threading import Thread -import datetime as dt - -import requests -from flask import Blueprint, request, jsonify -import paho.mqtt.publish as mqtt_publish - - -blueprint = Blueprint('detection', __name__) - - -def get_sunset_time() -> dt.datetime: - sunset_date = dt.datetime.now().date() - try: - IPGEOLOCATION_API_KEY = os.environ['IPGEOLOCATION_API_KEY'] - ipgeolocation_api_response = requests.get(f'https://api.ipgeolocation.io/astronomy?apiKey={IPGEOLOCATION_API_KEY}&location=Winter+Haven,+FL') - ipgeolocation_api_response.raise_for_status() - - astronomical_json = json.loads(ipgeolocation_api_response.content) - sunset_time = dt.datetime.strptime(astronomical_json['sunset'], '%H:%M').time() - except Exception: - traceback.print_exc() - sunset_time = dt.time(20, 00, 00) - finally: - if sunset_time < dt.datetime.now().time(): - # Sunset has already passed today - sunset_date += dt.timedelta(days=1) - return dt.datetime.combine(sunset_date, sunset_time) - - -def reset_all_camera_detection_at_sunset() -> None: - while True: - try: - # Get names of cameras with detection enabled in configuration - frigate_api_response = requests.get('http://frigate:5000/api/config') - frigate_api_response.raise_for_status() - frigate_camera_config = json.loads(frigate_api_response.content)['cameras'] - - sunset_time = get_sunset_time() + dt.timedelta(minutes=30) - print(f'Waiting until {sunset_time} to reset detection for all cameras...', file=sys.stderr) - seconds_until_sunset = (sunset_time - dt.datetime.now()).total_seconds() - time.sleep(seconds_until_sunset) - - for camera_name in frigate_camera_config: - camera_enabled = frigate_camera_config[camera_name].get('enabled', True) - detection_enabled = frigate_camera_config[camera_name].get('detect', {}).get('enabled', True) - - if camera_enabled and detection_enabled: - set_camera_detection(camera_name, True) - except Exception: - traceback.print_exc() - - -def set_camera_detection(camera: str, value: bool, delay: int = 0) -> None: - time.sleep(delay) - - mqtt_auth = {'username': os.environ.get('MQTT_USERNAME'), 'password': os.environ.get('MQTT_PASSWORD')} - if not all(mqtt_auth.values()): - mqtt_auth = None - - mqtt_publish.single(f'frigate/{camera}/detect/set', 'ON' if value else 'OFF', hostname='mqtt', port=1883, auth=mqtt_auth) - - -@blueprint.route('/camera//detect', methods=['POST']) -def camera_detect_POST(camera): - if not request.json: - return jsonify({ - 'status': 'failure', - 'description': 'Request body needs to be in JSON format' - }), 400 - - value = request.json.get('value', True) - set_camera_detection(camera, value) - - duration = request.json.get('duration', 0) - if duration > 0: - # Start sleeping thread to revert value after duration - thread = Thread(target=set_camera_detection, args=(camera, not value, duration * 60)) - thread.start() - - return jsonify({ - 'status': 'success' - }), 200 - - -sunset_reset_thread = Thread(target=reset_all_camera_detection_at_sunset, args=()) -sunset_reset_thread.start() diff --git a/webcontrol/src/server.py b/webcontrol/src/server.py deleted file mode 100644 index db2c3b4..0000000 --- a/webcontrol/src/server.py +++ /dev/null @@ -1,20 +0,0 @@ -from flask import Flask, jsonify - -import detection - - -app = Flask(__name__) -app.register_blueprint(detection.blueprint) - - -@app.route('/', methods=['GET']) -def home_GET(): - return jsonify({ - 'status': 'success', - 'name': 'webcontrol', - 'description': 'This is a custom webcontrol API for Frigate that allows minimal control over the NVR system through an HTTP API' - }), 200 - - -if __name__ == '__main__': - app.run(host='0.0.0.0', port=80, debug=False) diff --git a/webcontrol/src/webcontrol/__init__.py b/webcontrol/src/webcontrol/__init__.py new file mode 100644 index 0000000..007f80d --- /dev/null +++ b/webcontrol/src/webcontrol/__init__.py @@ -0,0 +1,4 @@ +from webcontrol.api import api +from webcontrol.utils.logging import configure_logging + +configure_logging() diff --git a/webcontrol/src/webcontrol/api/__init__.py b/webcontrol/src/webcontrol/api/__init__.py new file mode 100644 index 0000000..43bb24c --- /dev/null +++ b/webcontrol/src/webcontrol/api/__init__.py @@ -0,0 +1,18 @@ +import logging + +from fastapi import FastAPI + +from webcontrol.api.detection import api as detection_api + +logger = logging.getLogger(__name__) + +api = FastAPI() +api.include_router(detection_api) + + +@api.get("/") +async def get_root() -> dict[str, str]: + return { + "message": "This is a Frigate webcontrol API" + } + diff --git a/webcontrol/src/webcontrol/api/detection.py b/webcontrol/src/webcontrol/api/detection.py new file mode 100644 index 0000000..20cc021 --- /dev/null +++ b/webcontrol/src/webcontrol/api/detection.py @@ -0,0 +1,36 @@ +import datetime as dt +import logging + +from fastapi import APIRouter +from pydantic import BaseModel + +from webcontrol.detection import set_camera_detection, reset_all_cameras_detection_after_sunset +from webcontrol.utils.asyncio import create_task, schedule_coroutine + +logger = logging.getLogger(__name__) + +api = APIRouter() + + +@api.on_event("startup") +def schedule_reset_all_cameras_detection_after_sunset() -> None: + create_task(reset_all_cameras_detection_after_sunset()) + + +class TemporaryDetectionSettings(BaseModel): + detection: bool = True + duration: int = 0 + + +@api.post("/camera/{camera}/detection") +async def post_camera_detection(camera: str, temporary_detection_settings: TemporaryDetectionSettings) -> dict[str, str]: + await set_camera_detection(camera, temporary_detection_settings.detection) + if temporary_detection_settings.duration > 0: + schedule_coroutine( + set_camera_detection(camera, not temporary_detection_settings.detection), + dt.timedelta(minutes=temporary_detection_settings.duration) + ) + + return { + "status": "success" + } diff --git a/webcontrol/src/webcontrol/detection.py b/webcontrol/src/webcontrol/detection.py new file mode 100644 index 0000000..84a75af --- /dev/null +++ b/webcontrol/src/webcontrol/detection.py @@ -0,0 +1,33 @@ +import asyncio +import datetime as dt +import logging +import os + +import paho.mqtt.publish as mqtt_publish + +from webcontrol.frigate_config import FrigateConfigFile +from webcontrol.nighttime import get_nighttimes + +logger = logging.getLogger(__name__) + + +async def set_camera_detection(camera: str, detection: bool) -> None: + mqtt_auth = {"username": os.environ.get("MQTT_USERNAME"), "password": os.environ.get("MQTT_PASSWORD")} + if not all(mqtt_auth.values()): + mqtt_auth = None + + logger.debug(f"Setting {camera} camera {detection=}") + mqtt_publish.single(f"frigate/{camera}/detect/set", "ON" if detection else "OFF", hostname="mqtt", port=1883, auth=mqtt_auth) + logger.info(f"Set {camera} camera {detection=}") + + +async def reset_all_cameras_detection_after_sunset() -> None: + frigate_config_file = FrigateConfigFile() + async for nighttime in get_nighttimes("Winter Haven, FL"): + logger.info(f"Waiting until {nighttime} to reset detection for all cameras") + await asyncio.sleep((nighttime - dt.datetime.now()).total_seconds()) + + await frigate_config_file.reload() + active_and_detection_enabled_cameras = set(frigate_config_file.active_cameras).intersection(frigate_config_file.detection_enabled_cameras) + for camera in active_and_detection_enabled_cameras: + await set_camera_detection(camera, True) diff --git a/webcontrol/src/webcontrol/frigate_config.py b/webcontrol/src/webcontrol/frigate_config.py new file mode 100644 index 0000000..0cdd0a2 --- /dev/null +++ b/webcontrol/src/webcontrol/frigate_config.py @@ -0,0 +1,46 @@ +import logging +import os + +import yaml + +logger = logging.getLogger(__name__) + + +class FrigateConfigFile: + def __init__(self, filepath: str = os.environ["FRIGATE_CONFIG_FILE"]) -> None: + self._filepath = filepath + self._config = {} + + async def reload(self) -> None: + logger.debug("Loading Frigate configuration file") + try: + with open(self._filepath, "r") as frigate_config_yaml: + self._config = yaml.safe_load(frigate_config_yaml) + except Exception as e: + if self._config: + logger.error("Failed to load Frigate config file, falling back to previous value", exc_info=e) + else: + raise + logger.debug("Loaded Frigate configuration file") + + @property + def active_cameras(self) -> list[str]: + if not self._config: + raise ValueError("Configuration file not yet loaded from Frigate") + + return [ + camera + for camera in self._config["cameras"] + if self._config["cameras"][camera].get("enabled", True) + ] + + @property + def detection_enabled_cameras(self) -> list[str]: + if not self._config: + raise ValueError("Configuration file not yet loaded from Frigate") + + return [ + camera + for camera in self._config["cameras"] + if self._config["cameras"][camera].get("detect", {}).get("enabled", True) + ] diff --git a/webcontrol/src/webcontrol/nighttime.py b/webcontrol/src/webcontrol/nighttime.py new file mode 100644 index 0000000..538ae34 --- /dev/null +++ b/webcontrol/src/webcontrol/nighttime.py @@ -0,0 +1,39 @@ +import datetime as dt +import json +import logging +import os +import urllib.parse +from collections.abc import AsyncGenerator + +import aiohttp + +logger = logging.getLogger(__name__) + +SUNSET_FALLBACK = dt.time(19, 0, 0) +DARKNESS_DELAY = dt.timedelta(minutes=30) + + +async def get_nighttimes(location: str) -> AsyncGenerator[dt.datetime, None]: + async with aiohttp.ClientSession(raise_for_status=True) as aiohttp_session: + date = dt.date.today() + sunset_time = SUNSET_FALLBACK + + while True: + try: + ipgeolocation_api_url = "https://api.ipgeolocation.io/astronomy?apiKey={api_key}&location={location}&date={date}".format( + api_key=urllib.parse.quote(os.environ["IPGEOLOCATION_API_KEY"]), + location=urllib.parse.quote(location), + date=urllib.parse.quote(date.strftime("%Y-%m-%d")) + ) + async with aiohttp_session.get(ipgeolocation_api_url) as response: + response_json = json.loads(await response.read()) + sunset_time = dt.datetime.strptime(response_json["sunset"], "%H:%M").time() + except Exception as e: + logger.error(f"Failed to query sunset time, falling back to {sunset_time}", exc_info=e) + + nighttime = dt.datetime.combine(date, sunset_time) + DARKNESS_DELAY + date += dt.timedelta(days=1) + if nighttime < dt.datetime.now(): + continue + yield nighttime + diff --git a/webcontrol/src/webcontrol/utils/__init__.py b/webcontrol/src/webcontrol/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/webcontrol/src/webcontrol/utils/asyncio.py b/webcontrol/src/webcontrol/utils/asyncio.py new file mode 100644 index 0000000..e0a9b5b --- /dev/null +++ b/webcontrol/src/webcontrol/utils/asyncio.py @@ -0,0 +1,18 @@ +import asyncio +import datetime as dt +from collections.abc import Awaitable + +tasks = set() + + +def create_task(coroutine: Awaitable[None]) -> None: + task = asyncio.create_task(coroutine) # type: ignore + task.add_done_callback(tasks.discard) + + +def schedule_coroutine(coroutine: Awaitable[None], delay: dt.timedelta) -> None: + async def wrapper() -> None: + await asyncio.sleep(delay.seconds) + await coroutine + + create_task(wrapper()) diff --git a/webcontrol/src/webcontrol/utils/logging.py b/webcontrol/src/webcontrol/utils/logging.py new file mode 100644 index 0000000..5b6f2a2 --- /dev/null +++ b/webcontrol/src/webcontrol/utils/logging.py @@ -0,0 +1,12 @@ +import logging +import logging.config + +import yaml + + +def configure_logging() -> None: + with open("configs/logging.yaml", "r") as logging_yaml: + logging_config = yaml.safe_load(logging_yaml) + + logging.config.dictConfig(logging_config) +