Rewrite with asyncio and FastAPI #5

Squashed commit of the following:

commit 6832d62dc8360bd20e92df5554dd36426119a50d
Author: Ashish D'Souza <sudouser512@gmail.com>
Date:   Sun Mar 24 03:17:38 2024 -0500

    Converted to asyncio
This commit is contained in:
Ashish D'Souza 2024-03-24 03:40:36 -05:00
parent b3a2076416
commit be3bd11632
24 changed files with 345 additions and 246 deletions

View File

@ -106,7 +106,12 @@ services:
timeout: 30s timeout: 30s
environment: environment:
IPGEOLOCATION_API_KEY: ${IPGEOLOCATION_API_KEY} IPGEOLOCATION_API_KEY: ${IPGEOLOCATION_API_KEY}
FRIGATE_CONFIG_FILE: /frigate_config/config.yaml
volumes: volumes:
- type: volume
source: config
target: /frigate_config
read_only: true
- type: bind - type: bind
source: /etc/localtime source: /etc/localtime
target: /etc/localtime target: /etc/localtime

View File

@ -14,10 +14,10 @@ class FrigateEventNotifier:
self.mqtt_client.on_connect = self._on_connect self.mqtt_client.on_connect = self._on_connect
self.mqtt_client.on_message = self._on_message 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_api_response.raise_for_status()
frigate_config = json.loads(frigate_api_response.content) 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.quiet_period = quiet_period
self.last_notification_time = {} 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): 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 # Quiet period has passed since the last notification for this camera
self.last_notification_time[camera] = now 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={ ntfy_api_response = requests.post("https://ntfy.homelab.net", json={
'topic': 'frigate_notifications', "topic": "frigate_notifications",
'title': 'Frigate', "title": "Frigate",
'message': f'{object_label.capitalize()} at {camera_location} ({score:.0%})', "message": f"{object_label.capitalize()} at {camera_location} ({score:.0%})",
'priority': priority, "priority": priority,
'click': f'https://frigate.homelab.net/cameras/{camera}', "click": f"https://frigate.homelab.net/cameras/{camera}",
'icon': 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/frigate.png', "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', "attach": f"https://frigate.homelab.net/api/events/{event_id}/thumbnail.jpg?format=android",
'actions': [ "actions": [
{ {
'action': 'http', "action": "http",
'label': 'Disable (30m)', "label": "Disable (30m)",
'url': f'https://frigate.homelab.net/webcontrol/camera/{camera}/detect', "url": f"https://frigate.homelab.net/webcontrol/camera/{camera}/detection",
'method': 'POST', "method": "POST",
'headers': { "headers": {
'Content-Type': 'application/json' "Content-Type": "application/json"
}, },
'body': json.dumps({ "body": json.dumps({
'value': False, "detection": False,
'duration': 30 "duration": 30
}), }),
'clear': True "clear": True
} }
] ]
}) })
ntfy_api_response.raise_for_status() ntfy_api_response.raise_for_status()
ntfy_api_response = requests.post('https://ntfy.homelab.net', json={ ntfy_api_response = requests.post("https://ntfy.homelab.net", json={
'topic': 'frigate_notifications_dad', "topic": "frigate_notifications_dad",
'title': 'Frigate', "title": "Frigate",
'message': f'{object_label.capitalize()} at {camera_location} ({score:.0%})', "message": f"{object_label.capitalize()} at {camera_location} ({score:.0%})",
'priority': priority, "priority": priority,
'click': f'https://frigate.homelab.net/cameras/{camera}', "click": f"https://frigate.homelab.net/cameras/{camera}",
'icon': 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/frigate.png', "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', "attach": f"https://frigate.homelab.net/api/events/{event_id}/thumbnail.jpg?format=android",
'actions': [ "actions": [
{ {
'action': 'http', "action": "http",
'label': 'DBL (30m)', "label": "DBL (30m)",
'url': f'https://frigate.homelab.net/webcontrol/camera/{camera}/detect', "url": f"https://frigate.homelab.net/webcontrol/camera/{camera}/detection",
'method': 'POST', "method": "POST",
'headers': { "headers": {
'Content-Type': 'application/json' "Content-Type": "application/json"
}, },
'body': json.dumps({ "body": json.dumps({
'value': False, "detection": False,
'duration': 30 "duration": 30
}) })
} }
] ]
@ -83,36 +83,36 @@ class FrigateEventNotifier:
ntfy_api_response.raise_for_status() ntfy_api_response.raise_for_status()
def start(self): def start(self):
self.mqtt_client.connect(host='mqtt', port=1883) self.mqtt_client.connect(host="mqtt", port=1883)
self.mqtt_client.loop_forever() self.mqtt_client.loop_forever()
def _on_connect(self, client, userdata, flags, rc): def _on_connect(self, client, userdata, flags, rc):
print(f'Connected with return code {rc}') print(f"Connected with return code {rc}")
client.subscribe('frigate/events') client.subscribe("frigate/events")
def _on_message(self, client, userdata, message): def _on_message(self, client, userdata, message):
payload = json.loads(message.payload.decode()) payload = json.loads(message.payload.decode())
camera = payload['after']['camera'] camera = payload["after"]["camera"]
object_label = payload['after']['label'] object_label = payload["after"]["label"]
if not self.camera_zones[camera]: if not self.camera_zones[camera]:
# No required zones, send notification on receipt of new event # No required zones, send notification on receipt of new event
if payload['type'] == 'new': if payload["type"] == "new":
event_id = payload['after']['id'] event_id = payload["after"]["id"]
score = payload['after']['top_score'] score = payload["after"]["top_score"]
self.send_notification(event_id, camera, object_label, score) self.send_notification(event_id, camera, object_label, score)
else: 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: 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]): 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'] event_id = payload["after"]["id"]
score = payload['after']['top_score'] score = payload["after"]["top_score"]
self.send_notification(event_id, camera, object_label, score) self.send_notification(event_id, camera, object_label, score)
break break
if __name__ == '__main__': if __name__ == "__main__":
frigate_event_notifier = FrigateEventNotifier(os.environ.get('MQTT_USERNAME'), os.environ.get('MQTT_PASSWORD')) frigate_event_notifier = FrigateEventNotifier(os.environ.get("MQTT_USERNAME"), os.environ.get("MQTT_PASSWORD"))
frigate_event_notifier.start() frigate_event_notifier.start()

View File

@ -2,7 +2,7 @@ version: 1
disable_existing_loggers: false disable_existing_loggers: false
formatters: formatters:
default: default:
format: '[{asctime}.{msecs:03.0f}][{levelname}] {name}:{lineno}:{funcName} - {message}' format: '[{asctime}.{msecs:03.0f}][{levelname}] {name}:{lineno} - {message}'
style: '{' style: '{'
datefmt: '%Y-%m-%d %H:%M:%S' datefmt: '%Y-%m-%d %H:%M:%S'
handlers: handlers:

View File

View File

@ -17,9 +17,9 @@ def entrypoint() -> None:
try: try:
asyncio.run(main()) asyncio.run(main())
except KeyboardInterrupt: except KeyboardInterrupt:
logger.info('Received Ctrl+C, exiting...') logger.info("Received Ctrl+C, exiting...")
if __name__ == '__main__': if __name__ == "__main__":
entrypoint() entrypoint()

View File

@ -2,8 +2,7 @@ import re
import json import json
import logging import logging
from contextlib import AsyncExitStack from contextlib import AsyncExitStack
from types import TracebackType from typing import ClassVar, Pattern
from typing import Any, ClassVar, Pattern, Self
import aiohttp import aiohttp
@ -11,58 +10,47 @@ logger = logging.getLogger(__name__)
class FrigateConfig: class FrigateConfig:
_URL_ADDRESS_REGEX: ClassVar[Pattern[str]] = re.compile('(^|(?<=://)|(?<=@))[a-z0-9.\\-]+(:[0-9]+)?($|(?=/))') _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'} _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: def __init__(self, frigate_base_url: str = "http://frigate:5000") -> None:
self._frigate_config_url = f'{frigate_base_url}/api/config' self._frigate_config_url = f"{frigate_base_url}/api/config"
self._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: async def refresh(self) -> None:
logger.debug('Fetching Frigate config...') logger.debug("Fetching Frigate configuration")
try: 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()) self._config = json.loads(await response.read())
logger.debug('Finished fetching Frigate config')
except Exception as e: except Exception as e:
if self._config: 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: else:
raise raise
logger.debug("Fetched Frigate configuration")
@property @property
def active_cameras(self) -> list[str]: def active_cameras(self) -> list[str]:
if 'cameras' not in self._config: if "cameras" not in self._config:
raise ValueError('Configuration not yet fetched from Frigate') 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 @property
def active_camera_addresses(self) -> dict[str, str]: def active_camera_addresses(self) -> dict[str, str]:
active_cameras = self.active_cameras 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 @classmethod
def _get_address_from_url(cls, url: str) -> str: def _get_address_from_url(cls, url: str) -> str:
match = cls._URL_ADDRESS_REGEX.search(url.lower()) match = cls._URL_ADDRESS_REGEX.search(url.lower())
if match is None: 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 # Handle case of wyze-bridge and hardcode cameras
if match.group().startswith('wyze-bridge'): if match.group().startswith("wyze-bridge"):
wyze_camera = url.lower().rsplit('/', 1)[-1].replace('-', '_') wyze_camera = url.lower().rsplit("/", 1)[-1].replace("-", "_")
return cls._WYZE_CAMERAS[wyze_camera] return cls._WYZE_CAMERAS[wyze_camera]
return match.group() return match.group()

View File

@ -17,11 +17,11 @@ class CameraMonitor:
self._wait_time = wait_time self._wait_time = wait_time
self._consecutive_down_threshold = consecutive_down_threshold self._consecutive_down_threshold = consecutive_down_threshold
self._camera_downtime = Counter() self._camera_downtime = Counter()
self._frigate_config: FrigateConfig self._frigate_config = FrigateConfig()
self._ntfy_notifier: NtfyNotifier self._ntfy_notifier = NtfyNotifier
async def _on_camera_up(self, camera: str) -> None: 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) await self._ntfy_notifier.send_notification(camera, True)
async def _on_camera_down(self, camera: str) -> None: async def _on_camera_down(self, camera: str) -> None:
@ -29,7 +29,7 @@ class CameraMonitor:
if camera not in self._frigate_config.active_cameras: if camera not in self._frigate_config.active_cameras:
return return
logger.info(f'Camera {camera} is down') logger.info(f"Camera {camera} is down")
await self._ntfy_notifier.send_notification(camera, False) await self._ntfy_notifier.send_notification(camera, False)
async def _on_camera_ping(self, camera: str, success: bool) -> None: async def _on_camera_ping(self, camera: str, success: bool) -> None:
@ -43,16 +43,13 @@ class CameraMonitor:
self._camera_downtime[camera] += 1 self._camera_downtime[camera] += 1
async def run(self) -> None: async def run(self) -> None:
async with AsyncExitStack() as async_exit_stack: await self._frigate_config.refresh()
self._frigate_config = await async_exit_stack.enter_async_context(FrigateConfig())
self._ntfy_notifier = await async_exit_stack.enter_async_context(NtfyNotifier())
while True: while True:
camera_ips = {camera: address.split(':', 1)[0] for camera, address in self._frigate_config.active_camera_addresses.items()} 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()) ping_results = await ip_ping_all(*camera_ips.values())
for i, camera in enumerate(camera_ips): for i, camera in enumerate(camera_ips):
await self._on_camera_ping(camera, ping_results[i]) await self._on_camera_ping(camera, ping_results[i])
logger.debug(f'Sleeping for {self._wait_time} seconds...') logger.debug(f"Sleeping for {self._wait_time} seconds...")
await asyncio.sleep(self._wait_time) await asyncio.sleep(self._wait_time)

View File

@ -1,7 +1,4 @@
import logging import logging
from contextlib import AsyncExitStack
from types import TracebackType
from typing import Self
import aiohttp import aiohttp
@ -9,29 +6,19 @@ logger = logging.getLogger(__name__)
class NtfyNotifier: 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._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: async def send_notification(self, camera: str, status: bool) -> None:
logger.debug(f'Sending notification for {camera=}...') async with aiohttp.ClientSession(raise_for_status=True) as aiohttp_session:
message = f'{camera} is back online' if status else f'{camera} is offline' logger.debug(f"Sending notification for {camera=}...")
await self._aiohttp_session.post(self._ntfy_url, ssl=False, json={ message = f"{camera} is back online" if status else f"{camera} is offline"
'topic': 'frigate_camera_uptime', await aiohttp_session.post(self._ntfy_url, ssl=False, json={
'title': 'Frigate', "topic": "frigate_camera_uptime",
'message': message, "title": "Frigate",
'priority': 3, "message": message,
'click': f'https://frigate.homelab.net/cameras/{camera}', "priority": 3,
'icon': 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/frigate.png', "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=}') logger.debug(f"Sent notification for {camera=}")

View File

@ -5,10 +5,10 @@ logger = logging.getLogger(__name__)
async def ip_ping(host: str) -> bool: async def ip_ping(host: str) -> bool:
logger.debug(f'Pinging {host}...') 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) 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() return_code = await process.wait()
logger.debug(f'Finished pinging {host}') logger.debug(f"Finished pinging {host}")
return return_code == 0 return return_code == 0
async def ip_ping_all(*hosts: str) -> list[bool]: async def ip_ping_all(*hosts: str) -> list[bool]:

View File

@ -1,10 +1,11 @@
import logging import logging
import logging.config
import yaml import yaml
def configure_logging() -> None: 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 = yaml.safe_load(logging_yaml)
logging.config.dictConfig(logging_config) logging.config.dictConfig(logging_config)

View File

@ -1,7 +1,8 @@
FROM python:3.11 FROM python:3.11
WORKDIR /code 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 RUN pip3 install --upgrade pip

View File

@ -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 paho-mqtt==1.6.1
requests==2.31.0

View File

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

View File

@ -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/<string: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()

View File

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

View File

@ -0,0 +1,4 @@
from webcontrol.api import api
from webcontrol.utils.logging import configure_logging
configure_logging()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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