http.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  1. import base64
  2. import datetime
  3. import json
  4. import glob
  5. import logging
  6. import os
  7. import time
  8. from functools import reduce
  9. from pathlib import Path
  10. import cv2
  11. import gevent
  12. import numpy as np
  13. from flask import (
  14. Blueprint,
  15. Flask,
  16. Response,
  17. current_app,
  18. jsonify,
  19. make_response,
  20. request,
  21. )
  22. from flask_sockets import Sockets
  23. from peewee import SqliteDatabase, operator, fn, DoesNotExist
  24. from playhouse.shortcuts import model_to_dict
  25. from frigate.const import CLIPS_DIR, RECORD_DIR
  26. from frigate.models import Event
  27. from frigate.stats import stats_snapshot
  28. from frigate.util import calculate_region
  29. from frigate.version import VERSION
  30. logger = logging.getLogger(__name__)
  31. bp = Blueprint("frigate", __name__)
  32. ws = Blueprint("ws", __name__)
  33. class MqttBackend:
  34. """Interface for registering and updating WebSocket clients."""
  35. def __init__(self, mqtt_client, topic_prefix):
  36. self.clients = list()
  37. self.mqtt_client = mqtt_client
  38. self.topic_prefix = topic_prefix
  39. def register(self, client):
  40. """Register a WebSocket connection for Mqtt updates."""
  41. self.clients.append(client)
  42. def publish(self, message):
  43. try:
  44. json_message = json.loads(message)
  45. json_message = {
  46. "topic": f"{self.topic_prefix}/{json_message['topic']}",
  47. "payload": json_message.get["payload"],
  48. "retain": json_message.get("retain", False),
  49. }
  50. except:
  51. logger.warning("Unable to parse websocket message as valid json.")
  52. return
  53. logger.debug(
  54. f"Publishing mqtt message from websockets at {json_message['topic']}."
  55. )
  56. self.mqtt_client.publish(
  57. json_message["topic"],
  58. json_message["payload"],
  59. retain=json_message["retain"],
  60. )
  61. def run(self):
  62. def send(client, userdata, message):
  63. """Sends mqtt messages to clients."""
  64. try:
  65. logger.debug(f"Received mqtt message on {message.topic}.")
  66. ws_message = json.dumps(
  67. {
  68. "topic": message.topic.replace(f"{self.topic_prefix}/", ""),
  69. "payload": message.payload.decode(),
  70. }
  71. )
  72. except:
  73. # if the payload can't be decoded don't relay to clients
  74. logger.debug(
  75. f"MQTT payload for {message.topic} wasn't text. Skipping..."
  76. )
  77. return
  78. for client in self.clients:
  79. try:
  80. client.send(ws_message)
  81. except:
  82. logger.debug(
  83. "Removing websocket client due to a closed connection."
  84. )
  85. self.clients.remove(client)
  86. self.mqtt_client.message_callback_add(f"{self.topic_prefix}/#", send)
  87. def start(self):
  88. """Maintains mqtt subscription in the background."""
  89. gevent.spawn(self.run)
  90. def create_app(
  91. frigate_config,
  92. database: SqliteDatabase,
  93. stats_tracking,
  94. detected_frames_processor,
  95. mqtt_client,
  96. ):
  97. app = Flask(__name__)
  98. sockets = Sockets(app)
  99. @app.before_request
  100. def _db_connect():
  101. database.connect()
  102. @app.teardown_request
  103. def _db_close(exc):
  104. if not database.is_closed():
  105. database.close()
  106. app.frigate_config = frigate_config
  107. app.stats_tracking = stats_tracking
  108. app.detected_frames_processor = detected_frames_processor
  109. app.register_blueprint(bp)
  110. sockets.register_blueprint(ws)
  111. app.mqtt_backend = MqttBackend(mqtt_client, frigate_config.mqtt.topic_prefix)
  112. app.mqtt_backend.start()
  113. return app
  114. @bp.route("/")
  115. def is_healthy():
  116. return "Frigate is running. Alive and healthy!"
  117. @bp.route("/events/summary")
  118. def events_summary():
  119. has_clip = request.args.get("has_clip", type=int)
  120. has_snapshot = request.args.get("has_snapshot", type=int)
  121. clauses = []
  122. if not has_clip is None:
  123. clauses.append((Event.has_clip == has_clip))
  124. if not has_snapshot is None:
  125. clauses.append((Event.has_snapshot == has_snapshot))
  126. if len(clauses) == 0:
  127. clauses.append((1 == 1))
  128. groups = (
  129. Event.select(
  130. Event.camera,
  131. Event.label,
  132. fn.strftime(
  133. "%Y-%m-%d", fn.datetime(Event.start_time, "unixepoch", "localtime")
  134. ).alias("day"),
  135. Event.zones,
  136. fn.COUNT(Event.id).alias("count"),
  137. )
  138. .where(reduce(operator.and_, clauses))
  139. .group_by(
  140. Event.camera,
  141. Event.label,
  142. fn.strftime(
  143. "%Y-%m-%d", fn.datetime(Event.start_time, "unixepoch", "localtime")
  144. ),
  145. Event.zones,
  146. )
  147. )
  148. return jsonify([e for e in groups.dicts()])
  149. @bp.route("/events/<id>", methods=("GET",))
  150. def event(id):
  151. try:
  152. return model_to_dict(Event.get(Event.id == id))
  153. except DoesNotExist:
  154. return "Event not found", 404
  155. @bp.route("/events/<id>", methods=("DELETE",))
  156. def delete_event(id):
  157. try:
  158. event = Event.get(Event.id == id)
  159. except DoesNotExist:
  160. return make_response(
  161. jsonify({"success": False, "message": "Event" + id + " not found"}), 404
  162. )
  163. media_name = f"{event.camera}-{event.id}"
  164. if event.has_snapshot:
  165. media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
  166. media.unlink(missing_ok=True)
  167. if event.has_clip:
  168. media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.mp4")
  169. media.unlink(missing_ok=True)
  170. event.delete_instance()
  171. return make_response(
  172. jsonify({"success": True, "message": "Event" + id + " deleted"}), 200
  173. )
  174. @bp.route("/events/<id>/thumbnail.jpg")
  175. def event_thumbnail(id):
  176. format = request.args.get("format", "ios")
  177. thumbnail_bytes = None
  178. try:
  179. event = Event.get(Event.id == id)
  180. thumbnail_bytes = base64.b64decode(event.thumbnail)
  181. except DoesNotExist:
  182. # see if the object is currently being tracked
  183. try:
  184. camera_states = current_app.detected_frames_processor.camera_states.values()
  185. for camera_state in camera_states:
  186. if id in camera_state.tracked_objects:
  187. tracked_obj = camera_state.tracked_objects.get(id)
  188. if not tracked_obj is None:
  189. thumbnail_bytes = tracked_obj.get_thumbnail()
  190. except:
  191. return "Event not found", 404
  192. if thumbnail_bytes is None:
  193. return "Event not found", 404
  194. # android notifications prefer a 2:1 ratio
  195. if format == "android":
  196. jpg_as_np = np.frombuffer(thumbnail_bytes, dtype=np.uint8)
  197. img = cv2.imdecode(jpg_as_np, flags=1)
  198. thumbnail = cv2.copyMakeBorder(
  199. img,
  200. 0,
  201. 0,
  202. int(img.shape[1] * 0.5),
  203. int(img.shape[1] * 0.5),
  204. cv2.BORDER_CONSTANT,
  205. (0, 0, 0),
  206. )
  207. ret, jpg = cv2.imencode(".jpg", thumbnail, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
  208. thumbnail_bytes = jpg.tobytes()
  209. response = make_response(thumbnail_bytes)
  210. response.headers["Content-Type"] = "image/jpg"
  211. return response
  212. @bp.route("/events/<id>/snapshot.jpg")
  213. def event_snapshot(id):
  214. jpg_bytes = None
  215. try:
  216. event = Event.get(Event.id == id)
  217. if not event.has_snapshot:
  218. return "Snapshot not available", 404
  219. # read snapshot from disk
  220. with open(
  221. os.path.join(CLIPS_DIR, f"{event.camera}-{id}.jpg"), "rb"
  222. ) as image_file:
  223. jpg_bytes = image_file.read()
  224. except DoesNotExist:
  225. # see if the object is currently being tracked
  226. try:
  227. camera_states = current_app.detected_frames_processor.camera_states.values()
  228. for camera_state in camera_states:
  229. if id in camera_state.tracked_objects:
  230. tracked_obj = camera_state.tracked_objects.get(id)
  231. if not tracked_obj is None:
  232. jpg_bytes = tracked_obj.get_jpg_bytes(
  233. timestamp=request.args.get("timestamp", type=int),
  234. bounding_box=request.args.get("bbox", type=int),
  235. crop=request.args.get("crop", type=int),
  236. height=request.args.get("h", type=int),
  237. )
  238. except:
  239. return "Event not found", 404
  240. except:
  241. return "Event not found", 404
  242. response = make_response(jpg_bytes)
  243. response.headers["Content-Type"] = "image/jpg"
  244. return response
  245. @bp.route("/events")
  246. def events():
  247. limit = request.args.get("limit", 100)
  248. camera = request.args.get("camera")
  249. label = request.args.get("label")
  250. zone = request.args.get("zone")
  251. after = request.args.get("after", type=float)
  252. before = request.args.get("before", type=float)
  253. has_clip = request.args.get("has_clip", type=int)
  254. has_snapshot = request.args.get("has_snapshot", type=int)
  255. include_thumbnails = request.args.get("include_thumbnails", default=1, type=int)
  256. clauses = []
  257. excluded_fields = []
  258. if camera:
  259. clauses.append((Event.camera == camera))
  260. if label:
  261. clauses.append((Event.label == label))
  262. if zone:
  263. clauses.append((Event.zones.cast("text") % f'*"{zone}"*'))
  264. if after:
  265. clauses.append((Event.start_time >= after))
  266. if before:
  267. clauses.append((Event.start_time <= before))
  268. if not has_clip is None:
  269. clauses.append((Event.has_clip == has_clip))
  270. if not has_snapshot is None:
  271. clauses.append((Event.has_snapshot == has_snapshot))
  272. if not include_thumbnails:
  273. excluded_fields.append(Event.thumbnail)
  274. if len(clauses) == 0:
  275. clauses.append((1 == 1))
  276. events = (
  277. Event.select()
  278. .where(reduce(operator.and_, clauses))
  279. .order_by(Event.start_time.desc())
  280. .limit(limit)
  281. )
  282. return jsonify([model_to_dict(e, exclude=excluded_fields) for e in events])
  283. @bp.route("/config")
  284. def config():
  285. return jsonify(current_app.frigate_config.to_dict())
  286. @bp.route("/version")
  287. def version():
  288. return VERSION
  289. @bp.route("/stats")
  290. def stats():
  291. stats = stats_snapshot(current_app.stats_tracking)
  292. return jsonify(stats)
  293. @bp.route("/<camera_name>/<label>/best.jpg")
  294. def best(camera_name, label):
  295. if camera_name in current_app.frigate_config.cameras:
  296. best_object = current_app.detected_frames_processor.get_best(camera_name, label)
  297. best_frame = best_object.get("frame")
  298. if best_frame is None:
  299. best_frame = np.zeros((720, 1280, 3), np.uint8)
  300. else:
  301. best_frame = cv2.cvtColor(best_frame, cv2.COLOR_YUV2BGR_I420)
  302. crop = bool(request.args.get("crop", 0, type=int))
  303. if crop:
  304. box = best_object.get("box", (0, 0, 300, 300))
  305. region = calculate_region(
  306. best_frame.shape, box[0], box[1], box[2], box[3], 1.1
  307. )
  308. best_frame = best_frame[region[1] : region[3], region[0] : region[2]]
  309. height = int(request.args.get("h", str(best_frame.shape[0])))
  310. width = int(height * best_frame.shape[1] / best_frame.shape[0])
  311. best_frame = cv2.resize(
  312. best_frame, dsize=(width, height), interpolation=cv2.INTER_AREA
  313. )
  314. ret, jpg = cv2.imencode(".jpg", best_frame, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
  315. response = make_response(jpg.tobytes())
  316. response.headers["Content-Type"] = "image/jpg"
  317. return response
  318. else:
  319. return "Camera named {} not found".format(camera_name), 404
  320. @bp.route("/<camera_name>")
  321. def mjpeg_feed(camera_name):
  322. fps = int(request.args.get("fps", "3"))
  323. height = int(request.args.get("h", "360"))
  324. draw_options = {
  325. "bounding_boxes": request.args.get("bbox", type=int),
  326. "timestamp": request.args.get("timestamp", type=int),
  327. "zones": request.args.get("zones", type=int),
  328. "mask": request.args.get("mask", type=int),
  329. "motion_boxes": request.args.get("motion", type=int),
  330. "regions": request.args.get("regions", type=int),
  331. }
  332. if camera_name in current_app.frigate_config.cameras:
  333. # return a multipart response
  334. return Response(
  335. imagestream(
  336. current_app.detected_frames_processor,
  337. camera_name,
  338. fps,
  339. height,
  340. draw_options,
  341. ),
  342. mimetype="multipart/x-mixed-replace; boundary=frame",
  343. )
  344. else:
  345. return "Camera named {} not found".format(camera_name), 404
  346. @bp.route("/<camera_name>/latest.jpg")
  347. def latest_frame(camera_name):
  348. draw_options = {
  349. "bounding_boxes": request.args.get("bbox", type=int),
  350. "timestamp": request.args.get("timestamp", type=int),
  351. "zones": request.args.get("zones", type=int),
  352. "mask": request.args.get("mask", type=int),
  353. "motion_boxes": request.args.get("motion", type=int),
  354. "regions": request.args.get("regions", type=int),
  355. }
  356. if camera_name in current_app.frigate_config.cameras:
  357. # max out at specified FPS
  358. frame = current_app.detected_frames_processor.get_current_frame(
  359. camera_name, draw_options
  360. )
  361. if frame is None:
  362. frame = np.zeros((720, 1280, 3), np.uint8)
  363. height = int(request.args.get("h", str(frame.shape[0])))
  364. width = int(height * frame.shape[1] / frame.shape[0])
  365. frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_AREA)
  366. ret, jpg = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
  367. response = make_response(jpg.tobytes())
  368. response.headers["Content-Type"] = "image/jpg"
  369. return response
  370. else:
  371. return "Camera named {} not found".format(camera_name), 404
  372. @bp.route("/vod/<path:path>")
  373. def vod(path):
  374. if not os.path.isdir(f"{RECORD_DIR}/{path}"):
  375. return "Recordings not found.", 404
  376. files = glob.glob(f"{RECORD_DIR}/{path}/*.mp4")
  377. files.sort()
  378. clips = []
  379. durations = []
  380. for filename in files:
  381. clips.append({"type": "source", "path": filename})
  382. video = cv2.VideoCapture(filename)
  383. duration = int(
  384. video.get(cv2.CAP_PROP_FRAME_COUNT) / video.get(cv2.CAP_PROP_FPS) * 1000
  385. )
  386. durations.append(duration)
  387. return jsonify(
  388. {
  389. "discontinuity": False,
  390. "durations": durations,
  391. "sequences": [{"clips": clips}],
  392. }
  393. )
  394. def imagestream(detected_frames_processor, camera_name, fps, height, draw_options):
  395. while True:
  396. # max out at specified FPS
  397. gevent.sleep(1 / fps)
  398. frame = detected_frames_processor.get_current_frame(camera_name, draw_options)
  399. if frame is None:
  400. frame = np.zeros((height, int(height * 16 / 9), 3), np.uint8)
  401. width = int(height * frame.shape[1] / frame.shape[0])
  402. frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_LINEAR)
  403. ret, jpg = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
  404. yield (
  405. b"--frame\r\n"
  406. b"Content-Type: image/jpeg\r\n\r\n" + jpg.tobytes() + b"\r\n\r\n"
  407. )
  408. @ws.route("/ws")
  409. def echo_socket(socket):
  410. current_app.mqtt_backend.register(socket)
  411. while not socket.closed:
  412. # Sleep to prevent *constant* context-switches.
  413. gevent.sleep(0.1)
  414. message = socket.receive()
  415. if message:
  416. current_app.mqtt_backend.publish(message)