Compare commits

...

59 Commits

Author SHA1 Message Date
Mark Cotton cc4d33d91e pulling in changes from EE-downloader-v3, trying to solve unauthenticated response on first request after login 2023-08-31 06:28:56 -05:00
Mark Cotton 6891d581a3 using the new url escaping in EagleEyev3 0.0.18 2023-08-30 09:01:40 -05:00
Mark Cotton a7d6911489 ignoring settings.py and tweaking routes output 2023-08-29 14:51:50 -05:00
Mark Cotton e2056290c9 fixing where these variables come from 2023-08-24 14:51:17 -05:00
Mark Cotton 316ea642d9 fixes #14 2023-08-24 14:40:11 -05:00
Ubuntu a91b48d260 increased logging severity in @login_requried 2023-08-22 14:53:31 +00:00
Mark Cotton e4274e3ea2 update requirements to use latest EagleEyev3 2023-08-21 11:22:42 -05:00
Mark Cotton ba7ecb3282 working on cross-session problem #18 2023-08-21 11:22:17 -05:00
Mark Cotton 516340f2b4 added more debug logging 2023-08-20 22:50:13 -05:00
Mark Cotton 5d736db324 changed login in @login_required, fixes #6, and fixed bug with setting log_level in settings.py 2023-08-20 22:44:44 -05:00
Mark Cotton 094b7b754f commenting out call to een.logout until they fix it 2023-08-20 22:15:33 -05:00
Mark Cotton b02ba38eea added @login_requried, fixes #6 2023-08-20 22:05:48 -05:00
Mark Cotton fb10657e60 removed redundant graph buttons, added status and esn as title text on camera name 2023-08-17 16:31:16 -05:00
Ubuntu a00347c7ee adding more typos 2023-08-17 14:52:03 +00:00
Mark Cotton e4dbb9c8e1 typos 2023-08-17 09:48:56 -05:00
Ubuntu 03cc8797bc Merge branch 'main' of 100.91.210.38:mcotton/EE-status-v3 2023-08-15 16:22:30 +00:00
Ubuntu 3b9b0795ff updated EagleEyev3 version to 0.0.13 2023-08-15 16:21:36 +00:00
Mark Cotton dff7923892 trying to solve issue with graphs not loading correctly, adding current timestamp over none, dealing with new unknown status, #15 2023-08-15 11:17:38 -05:00
Mark Cotton 24b6a36700 turning down logging from warn to debug, working on #15 2023-08-15 09:05:39 -05:00
Mark Cotton 2b78898f0b tweaking how status chart is filling in data and rendering, working on #15 2023-08-15 08:56:44 -05:00
Mark Cotton 55b3b82983 bump to use EagleEyev3 0.0.12 2023-08-15 08:03:15 -05:00
Mark Cotton 014b767265 updating to use latest EagleEyev3 for pagination support 2023-08-14 17:34:17 -05:00
Mark Cotton 9067a2a6ce showing camera online/offline/total counts, fixes #8 2023-08-14 17:33:19 -05:00
Mark Cotton 939553b4a5 added UI for handing Reseller user, allows switch, requires EagleEyev3 == 0.0.10 2023-08-10 13:05:23 -06:00
Mark Cotton 132addb162 Removing Jupyter playground from repo 2023-08-09 14:16:13 -06:00
Mark Cotton 28d5a853f3 added MIT license 2023-08-09 07:33:52 -06:00
Mark Cotton 382a3859cb css tweaks to make it a little better on iPhones 2023-08-08 10:47:06 -06:00
Mark Cotton f0a7366f31 APIv3 /events query limit raised from 6 to 24 hours 2023-08-08 08:39:48 -06:00
Mark Cotton 3183e109f7 added a new demo movie on the landing page 2023-08-07 17:22:22 -06:00
Mark Cotton 22bec51d81 changing events fetch options 2023-08-07 16:21:27 -06:00
Mark Cotton a44090f042 added progress bar for htmx shamelessly ripped-off from https://dev.to/amal/global-progress-bar-for-htmx-50fa 2023-08-07 14:55:20 -06:00
Mark Cotton 95fbcfd2d4 added param to query event list of number of days, showing options in event list 2023-08-07 14:43:23 -06:00
Mark Cotton d10789f002 forgot to apply the style to the offline cameras 2023-08-07 14:20:59 -06:00
Mark Cotton cd5db12a2f made the camera action buttons more obvious 2023-08-07 14:13:37 -06:00
Mark Cotton 9a46f72a51 cleaning up event list, making more consistent text-align 2023-08-07 13:56:18 -06:00
Mark Cotton dd77dec94a clean-up cameras list, adding bootstrap icons 2023-08-07 12:55:58 -06:00
Mark Cotton 81eb4daca3 more styling tweaks 2023-07-28 11:37:00 -06:00
Mark Cotton 53ae5aa8f1 added post-checkout hook to write git version to file 2023-07-28 11:06:18 -06:00
Mark Cotton 499e7fa836 added back a route for the landing page, css tweaks 2023-07-28 10:55:08 -06:00
Mark Cotton 67817f5094 added a theme from https://bootstrap.themes.guide/greyson/ 2023-07-28 10:41:05 -06:00
Mark Cotton 6a2923801f added a demo video to the cover page 2023-07-28 09:37:05 -06:00
Mark Cotton 032563c85d moving some configuration values into settings file 2023-07-27 22:57:17 -06:00
Mark Cotton 294dc70774 CSS tweak for mobile 2023-07-27 22:24:06 -06:00
Mark Cotton f20787b16c previous commit didn't work so reverting it and adding a unauthenticated landed page 2023-07-27 21:32:56 -06:00
Mark Cotton 1abb81cb4f seeing if this helps with cross-session problem 2023-07-26 13:21:23 -06:00
Mark Cotton 557cf2c681 pulling in changes from redesign branch 2023-07-26 07:34:33 -06:00
Mark Cotton 46d9423890 html tweak to make live_preview a little larger 2023-07-25 08:44:22 -06:00
Mark Cotton ee29551175 shirking chart, adding title, hoisting imports 2023-07-25 08:32:47 -06:00
Mark Cotton 5466aeee74 forgot to include dependencies 2023-07-24 15:22:54 -06:00
Mark Cotton f4676c2257 super un-efficient way to mangle data for graph 2023-07-24 15:10:38 -06:00
Mark Cotton 8eb3a45e2e updated README 2023-07-21 16:14:21 -06:00
Mark Cotton 9ad71170c8 Updated notebook, have to copy in an access_token which I don't like doing 2023-07-19 23:43:42 -06:00
Mark Cotton fbf09eb767 grouping online/offline cameras by those connected to bridges and those without bridge_id 2023-07-19 23:27:05 -06:00
Mark Cotton aea2873ac4 pinning to the minimum EagleEyev3 version that works 2023-07-19 23:12:21 -06:00
Mark Cotton 36336c72c1 deleting old files 2023-07-19 23:06:42 -06:00
Mark Cotton 2441e86dbc Changes to use EagleEyev3 module from pip 2023-07-19 23:05:29 -06:00
Mark Cotton df38fd7f74 fighting to battle with lingering server sessions not clearing 2023-07-14 13:02:26 -05:00
Mark Cotton a26ef602fa docker-izing it so it can be deployed 2023-07-14 12:06:57 -05:00
mcotton 842d067502 Update 'README.md' 2023-07-14 16:41:16 +00:00
26 changed files with 6363 additions and 1223 deletions

2
.gitignore vendored
View File

@ -8,3 +8,5 @@ my_settings.py
.ipynb_checkpoints/
.ipynb_checkpoints/*
flask_session/
git-version.txt
settings.py

15
Dockerfile Normal file
View File

@ -0,0 +1,15 @@
FROM python:3.9-slim
COPY ./requirements.txt /app/requirements.txt
WORKDIR /app
RUN apt update
RUN pip install --upgrade pip
RUN pip install -r requirements.txt
CMD [ "./startup.sh" ]

View File

@ -1,641 +0,0 @@
import json
import logging
import requests
from .settings import *
from datetime import datetime, timedelta
from pytz import timezone
from io import BytesIO
logging.basicConfig(level=logging.INFO)
class EagleEyev3():
"""
Class representing the EagleEyev3 client.
"""
def __init__(self):
"""
Initializes the EagleEyev3 client object.
"""
self.client_id = None
self.client_secret = None
self.access_token = None
self.refresh_token = None
self.redirect_uri = None
self._load_vars_from_settings()
self.user_base_url = None
self.current_user = None
self.users = []
self.bridges = []
self.cameras = []
self.switches = []
self.users = []
self.accounts = []
self.user_tz_obj = None
self.lazy_login = True
if self.lazy_login:
try:
self._load_access_token()
except FileNotFoundError as e:
logging.warn("self.lazy_login is set to {self.lazy_login} but could not find .lazy_login file to load")
def _load_vars_from_settings(self):
"""
Load variables from the settings module.
"""
self.client_id = settings.client_id
self.client_secret = settings.client_secret
self.server_protocol = settings.server_protocol
self.server_host = settings.server_host
self.server_port = settings.server_port
self.server_path = settings.server_path
# Combine server_protocol, server_host, and server_port to make the redirect_uri
# Note: Please see the note in settings.py about trailing slashes and modify this line if needed
self.redirect_uri = f"{self.server_protocol}://{self.server_host}:{self.server_port}/{self.server_path}"
def _save_access_token(self):
with open(".lazy_login", "w") as json_file:
json.dump({
'access_token': self.access_token,
'refresh_token': self.refresh_token,
'current_user': self.current_user
}, json_file)
def _load_access_token(self):
with open(".lazy_login", "r") as json_file:
saved = json.load(json_file)
if 'access_token' in saved:
self.access_token = saved['access_token']
if 'refresh_token' in saved:
self.refresh_token = saved['refresh_token']
self.get_base_url(cascade=True)
def time_now(self):
return datetime.now(tz=self.user_tz_obj).isoformat(timespec='milliseconds')
def time_before(self, ts=None, hours=6):
if ts == None:
ts = datetime.now(tz=self.user_tz_obj)
if type(ts) == str:
ts = datetime.fromisoformat(ts)
return (ts - timedelta(hours=hours)).isoformat(timespec='milliseconds')
def login_tokens(self, code=None, cascade=True):
"""
Obtains login tokens using the authorization code.
Args:
code (str): The authorization code.
cascade (bool): Indicates whether to cascade and get the base URL and current user information.
Returns:
dict: Dictionary containing the success status, response HTTP status code, data, and current user information.
"""
baseUrl = "https://auth.eagleeyenetworks.com/oauth2/token"
pathUrl = f"?grant_type=authorization_code&scope=vms.all&code={code}&redirect_uri={self.redirect_uri}" # Note the trailing slash, make sure it matches the whitelist
url = baseUrl + pathUrl
# Send a POST request to obtain login tokens
response = requests.post(url, auth=(self.client_id, self.client_secret))
response_json = response.json()
logging.info(f"{response.status_code} in login_tokens")
if response.status_code == 200:
success = True
self.access_token = response_json['access_token']
self.refresh_token = response_json['refresh_token']
if self.lazy_login:
self._save_access_token()
if cascade:
self.get_base_url()
else:
success = False
return {
"success": success,
"response_http_status": response.status_code,
"data": response_json,
'current_user': self.current_user
}
def logout(self):
"""
Revokes token.
Returns:
dict: Dictionary containing the success status, response HTTP status code, and data.
"""
url = "https://auth.eagleeyenetworks.com/oauth2/revoke"
payload = {
"token": self.access_token,
"token_type_hint": "access_token"
}
headers = {
"Authorization": f"Bearer {self.access_token}",
"Accept": "application/json",
"Content-type": "application/json"
}
# Send a POST request to obtain the base URL
response = requests.post(url, json=payload, headers=headers)
response_json = response.json()
logging.info(f"{response.status_code} in logout")
if response.status_code == 200:
success = True
else:
success = False
logging.info(f"call to logout: {response_json}")
self.access_token = None
self.refresh_token = None
return {
"success": success,
"response_http_status": response.status_code,
"data": response_json
}
def get_base_url(self, cascade=True):
"""
Obtains the base URL for the user.
Args:
cascade (bool): Indicates whether to cascade and get the current user information.
Returns:
dict: Dictionary containing the success status, response HTTP status code, and data.
"""
url = "https://api.eagleeyenetworks.com/api/v3.0/clientSettings"
headers = {
"Authorization": f"Bearer {self.access_token}",
"Accept": "application/json"
}
# Send a GET request to obtain the base URL
response = requests.get(url, headers=headers)
response_json = response.json()
logging.info(f"{response.status_code} in get_base_url")
if response.status_code == 200:
success = True
if 'httpsBaseUrl' in response_json and 'hostname' in response_json['httpsBaseUrl']:
self.user_base_url = response_json['httpsBaseUrl']['hostname']
if cascade:
self.get_current_user()
else:
success = False
return {
"success": success,
"response_http_status": response.status_code,
"data": response_json
}
def get_current_user(self):
"""
Obtains the information of the current user.
Returns:
dict: Dictionary containing the success status, response HTTP status code, and data.
"""
url = f"https://{self.user_base_url}/api/v3.0/users/self?include=timeZone"
headers = {
"Authorization": f"Bearer {self.access_token}",
"Accept": "application/json"
}
# Send a GET request to obtain the current user information
response = requests.get(url, headers=headers)
response_json = response.json()
logging.info(f"{response.status_code} in get_current_user")
if response.status_code == 200:
success = True
self.current_user = response_json
self.user_tz_obj = timezone(response_json['timeZone']['timeZone'])
else:
success = False
return {
"success": success,
"response_http_status": response.status_code,
"data": response_json
}
def get_list_of_users(self):
"""
Obtains the list of users.
Returns:
dict: Dictionary containing the success status, response HTTP status code, and data.
"""
url = f"https://{self.user_base_url}/api/v3.0/users?include=timeZone"
headers = {
"Authorization": f"Bearer {self.access_token}",
"Accept": "application/json"
}
response = requests.get(url, headers=headers)
response_json = response.json()
logging.info(f"{response.status_code} in get_list_of_users")
if response.status_code == 200:
success = True
self.users = [i for i in response_json['results']]
else:
success = False
return {
"success": success,
"response_http_status": response.status_code,
"data": response_json
}
def get_list_of_cameras(self):
"""
Obtains the list of cameras.
Returns:
dict: Dictionary containing the success status, response HTTP status code, and data.
"""
url = f"https://{self.user_base_url}/api/v3.0/cameras?include=status"
headers = {
"Authorization": f"Bearer {self.access_token}",
"Accept": "application/json"
}
response = requests.get(url, headers=headers)
response_json = response.json()
logging.info(f"{response.status_code} in get_list_of_cameras")
if response.status_code == 200:
success = True
self.cameras = [
Camera(id=i['id'],\
name=i['name'],\
status=i['status'],\
account_id=i['accountId'],\
bridge_id=i['bridgeId'],\
user_base_url=self.user_base_url,\
een_instance=self)
for i in response_json['results']]
for camera in self.cameras:
camera.user_base_url = self.user_base_url
else:
success = False
return {
"success": success,
"response_http_status": response.status_code,
"data": response_json
}
def get_list_of_bridges(self):
"""
Obtains the list of bridges.
Returns:
dict: Dictionary containing the success status, response HTTP status code, and data.
"""
url = f"https://{self.user_base_url}/api/v3.0/bridges"
headers = {
"Authorization": f"Bearer {self.access_token}",
"Accept": "application/json"
}
response = requests.get(url, headers=headers)
response_json = response.json()
logging.info(f"{response.status_code} in get_list_of_bridges")
if response.status_code == 200:
success = True
self.bridges = [i for i in response_json['results']]
else:
success = False
return {
"success": success,
"response_http_status": response.status_code,
"data": response_json
}
def get_list_of_switches(self):
"""
Obtains the list of switches.
Returns:
dict: Dictionary containing the success status, response HTTP status code, and data.
"""
url = f"https://{self.user_base_url}/api/v3.0/switches"
headers = {
"Authorization": f"Bearer {self.access_token}",
"Accept": "application/json"
}
response = requests.get(url, headers=headers)
response_json = response.json()
logging.info(f"{response.status_code} in get_list_of_switches")
if response.status_code == 200:
success = True
self.switches = [i for i in response_json['results']]
else:
success = False
return {
"success": success,
"response_http_status": response.status_code,
"data": response_json
}
def get_list_of_available_devices(self, deviceType__in="camera"):
"""
Obtains the list of available devices.
Returns:
dict: Dictionary containing the success status, response HTTP status code, and data.
"""
url = f"https://{self.user_base_url}/api/v3.0/availableDevices?deviceType__in={deviceType__in}"
headers = {
"Authorization": f"Bearer {self.access_token}",
"Accept": "application/json"
}
response = requests.get(url, headers=headers)
response_json = response.json()
logging.info(f"{response.status_code} in get_list_of_available_devices")
if response.status_code == 200:
success = True
else:
success = False
return {
"success": success,
"response_http_status": response.status_code,
"data": response_json
}
def get_list_of_multi_cameras(self):
"""
Obtains the list of multi-cameras.
Returns:
dict: Dictionary containing the success status, response HTTP status code, and data.
"""
url = f"https://{self.user_base_url}/api/v3.0/multiCameras"
headers = {
"Authorization": f"Bearer {self.access_token}",
"Accept": "application/json"
}
response = requests.get(url, headers=headers)
response_json = response.json()
logging.info(f"{response.status_code} in get_list_of_multi_cameras")
if response.status_code == 200:
success = True
else:
success = False
return {
"success": success,
"response_http_status": response.status_code,
"data": response_json
}
def get_list_of_feeds(self):
"""
Obtains the list of feeds.
Returns:
dict: Dictionary containing the success status, response HTTP status code, and data.
"""
url = f"https://{self.user_base_url}/api/v3.0/feeds"
headers = {
"Authorization": f"Bearer {self.access_token}",
"Accept": "application/json"
}
response = requests.get(url, headers=headers)
response_json = response.json()
logging.info(f"{response.status_code} in get_list_of_feeds")
if response.status_code == 200:
success = True
else:
success = False
return {
"success": success,
"response_http_status": response.status_code,
"data": response_json
}
def get_camera_by_id(self, esn):
found_camera = None
for camera in self.cameras:
if camera.id == esn:
found_camera = camera
break
if found_camera == None:
camera = Camera()
logging.debug(f"returning camera {camera} for search query {esn}")
return camera
class Device():
def __init__(self, id=None, name=None, status=None, account_id=None, user_base_url=None, een_instance=None):
self.id = id
self.name = name
self.status = status
self.account_id = account_id
self.user_base_url = user_base_url,
self.een_instance = een_instance
def get_id(self):
return self.id
def get_status(self):
if 'connectionStatus' in self.status:
return self.status['connectionStatus']
return None
def is_online(self):
if 'connectionStatus' in self.status:
return self.status['connectionStatus'] == "online"
return None
def is_offline(self):
if 'connectionStatus' in self.status:
return self.status['connectionStatus'] == "deviceOffline" \
or self.status['connectionStatus'] == "bridgeOffline" \
or self.status['connectionStatus'] == "error"
return None
def __repr__(self):
if self.is_online():
online = ''
elif self.is_offline():
online = ''
else:
online = ''
return f"{online} [{self.id}] - {self.name}"
class Camera(Device):
def __init__(self, id=None, name=None, status=None, account_id=None, bridge_id=None, user_base_url=None, een_instance=None):
super().__init__(id=id, name=name, status=status, account_id=account_id, user_base_url=user_base_url, een_instance=een_instance)
self.bridge_id = bridge_id
self.previews = []
self.videos = []
self.events = {
'status': [],
'motion': []
}
def get_list_of_events(self, start_timestamp=None, end_timestamp=None):
"""
Obtains the list of events.
Returns:
dict: Dictionary containing the success status, response HTTP status code, and data.
"""
if start_timestamp == None or end_timestamp == None:
logging.debug(f"get_list_of_events called without timestamp")
return {
"success": False,
"response_http_status": None,
"data": { 'msg': 'get_list_of_events called without required args, needs start_timestamp, end_timestamp' }
}
url = f"https://{self.user_base_url}/api/v3.0/events?pageSize=100&include=een.deviceCloudStatusUpdate.v1&startTimestamp__gte={start_timestamp}&endTimestamp__lte={end_timestamp}&actor=camera%3A{self.id}&type__in=een.deviceCloudStatusUpdateEvent.v1"
headers = {
"Authorization": f"Bearer {self.een_instance.access_token}",
"Accept": "application/json"
}
try:
response = requests.get(url, headers=headers, timeout=(3, 5))
response_json = response.json()
logging.debug(f"{response.status_code} returned from {url} with {headers} and {response.text}")
logging.info(f"{response.status_code} in get_list_of_events")
if response.status_code == 200:
success = True
# filter events by type
[self.events['status'].append(i) for i in response.json()['results'] if i['type'] == 'een.deviceCloudStatusUpdateEvent.v1']
[self.events['motion'].append(i) for i in response.json()['results'] if i['type'] == 'een.motionDetectionEvent.v1']
# remove duplicates
seen = set()
self.events['status'] = [event for event in self.events['status'] if event['endTimestamp'] and event['id'] not in seen and not seen.add(event['id'])]
seen = set()
self.events['motion'] = [event for event in self.events['motion'] if event['id'] not in seen and not seen.add(event['id'])]
# sort by event startTimestamp descending
self.events['status'] = sorted(self.events['status'], key=lambda x: x['startTimestamp'], reverse=True)
self.events['motion'] = sorted(self.events['motion'], key=lambda x: x['startTimestamp'], reverse=True)
else:
success = False
except requests.exceptions.Timeout:
logging.warn(f"timeout expired for {self.id} get_llist_of_events()")
return {
"success": False,
"response_http_status": 0,
"data": None
}
except requests.exceptions.RequestException as e:
logging.warn(e)
return {
"success": False,
"response_http_status": 0,
"data": None
}
return {
"success": success,
"response_http_status": response.status_code,
"data": response_json
}
def get_live_preview(self):
url = f"https://{self.user_base_url}/api/v3.0/media/liveImage.jpeg?deviceId={self.id}&type=preview"
headers = {
"Authorization": f"Bearer {self.een_instance.access_token}",
"Accept": "image/jpeg"
}
try:
response = requests.get(url, headers=headers, timeout=(3, 5))
logging.info(f"{response.status_code} in get_live_preview")
except requests.exceptions.Timeout:
logging.warn(f"timeout expired for {self.id} get_live_preview()")
response = None
except requests.exceptions.RequestException as e:
logging.warn(e)
response = None
return response

View File

@ -1,14 +0,0 @@
# Set up your application and get client id/secrete first
# https://developerv3.eagleeyenetworks.com/page/my-application
client_id = ""
client_secret = ""
# you will need to add approved redirect_uris in your application
# this examples assumes you've added http://127.0.0.1:3333/login_callback
# change the following variables if you did something different
# Note: do not use localhost for server_host, use 127.0.0.1 instead
server_protocol = "http"
server_host = "127.0.0.1"
server_port = "3333"
server_path = "login_callback"

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Mark Cotton
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,459 +0,0 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "d4582341",
"metadata": {},
"source": [
"# EagleEyev3 Playground\n",
"\n",
"To make this playground work, it is easier to read the `access_token` off the filesystem but you can always run the example server `python server.py` to go thorugh the Oauth2 flow. By default it will save the `access_token` into a file named `.lazy_login`. The module looks for that file and tries reading t"
]
},
{
"cell_type": "markdown",
"id": "8355d241",
"metadata": {},
"source": [
"## Import Module"
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "1394471a",
"metadata": {},
"outputs": [],
"source": [
"from EagleEyev3 import EagleEyev3"
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "486a2537",
"metadata": {},
"outputs": [
{
"name": "stderr",
"output_type": "stream",
"text": [
"INFO:root:200 in get_base_url\n",
"INFO:root:200 in get_current_user\n"
]
}
],
"source": [
"een = EagleEyev3()"
]
},
{
"cell_type": "markdown",
"id": "51b8b66e",
"metadata": {},
"source": [
"## Adjust Log Level"
]
},
{
"cell_type": "code",
"execution_count": 3,
"id": "06d91db2",
"metadata": {},
"outputs": [],
"source": [
"import logging\n",
"logger = logging.getLogger()\n",
"#logger.setLevel('DEBUG')\n",
"#logger.setLevel('INFO')\n",
"logger.setLevel('WARN')\n",
"#logger.setLevel('ERROR')\n",
"#logger.setLevel('CRITICAL')"
]
},
{
"cell_type": "markdown",
"id": "0311109c-869c-4190-97c1-a6e717a8eeba",
"metadata": {},
"source": [
"## Who am I"
]
},
{
"cell_type": "code",
"execution_count": 4,
"id": "e14e2be5-a5f9-4b8c-ae60-76c61cb61b8b",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"'Mark Cotton - mcotton@mcottondesign.com'"
]
},
"execution_count": 4,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"f\"{een.current_user['firstName']} {een.current_user['lastName']} - {een.current_user['email']}\""
]
},
{
"cell_type": "code",
"execution_count": 5,
"id": "4ef47ae2-a010-4b7e-87f6-3dbf0a047e16",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"'eyJraWQiOiI2ODYxYjBjYS0wZjI2LTExZWQtODYxZC0wMjQyYWMxMjAwMDIiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJjYWZlZGVmMiIsImF1ZCI6InZtcy5hcGkiLCJpc3MiOiJ2bXMuYXV0aC52MSIsInZtc19hY2NvdW50IjoiMDAwMjgyMDEiLCJleHAiOjE2ODkzNzA1MTIsImlhdCI6MTY4ODc2NTcxMiwianRpIjoiYTM1Nzk3MmRmNzdiNTAwYTQ3NjBlNGY1MGYwNjNhMjUiLCJjbGllbnRfaWQiOiIzMmRhYzMzMDY0OWI0ODI3OWY5Y2FjYzJiNmY1N2FlNSIsInZtc19jbHVzdGVyIjoiYzAxMiJ9.EEmCaFqduOV5_cQ-VejeBXGbKLj1yHrxGkcMgqPUN1jYY0wR9bO7rkEwQh59Dj-1fr8pKsbrUr6DPDLfkaSIRlxpjdlsEBdzAmoZUpzPUfL9QzJu0C04OJU0gcBh7fwur7L2fMVthaZ6OKfThdM29qzRH5RR9gSAJGeNe08n4IvFVvuL80yvczgOiQwSzkg4PuHpbImDa44U7-qC8CrRvQ_TqsX6ziNzw-XmxZxXEwtZxkAOFDFaIsA8V4ezbQ_TSY6EElCnyyKLLI46-KIsGevx8dxa2NrhPFvC725dMhg-OPaCEF62sOlTrlHVBcv_e9MBk1VZLoDLStkSvU7Dxg'"
]
},
"execution_count": 5,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"een.access_token"
]
},
{
"cell_type": "markdown",
"id": "a22ff6c2",
"metadata": {},
"source": [
"## Get Cameras"
]
},
{
"cell_type": "code",
"execution_count": 6,
"id": "bb457850",
"metadata": {},
"outputs": [],
"source": [
"ret = een.get_list_of_cameras()"
]
},
{
"cell_type": "code",
"execution_count": 7,
"id": "c43f1db1",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"[✅ [1001423e] - ATM & Wine,\n",
" ✅ [100d8666] - Cash Register,\n",
" ✅ [10012735] - Fuel Dock,\n",
" ✅ [1002584c] - Safe]"
]
},
"execution_count": 7,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"[i for i in een.cameras if i.is_online()]"
]
},
{
"cell_type": "code",
"execution_count": 8,
"id": "f4c6fe67",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"[ [10090759] - Benny Camera,\n",
" [1003e10b] - Driveway,\n",
" [100ba388] - Front Door,\n",
" [100b7b3b] - Max Camera,\n",
" [1009ae55] - Office]"
]
},
"execution_count": 8,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"[i for i in een.cameras if not i.is_online()]"
]
},
{
"cell_type": "markdown",
"id": "8c140aaf-766f-4255-94ef-199d17cbc7a6",
"metadata": {},
"source": [
"## Getting list of Events"
]
},
{
"cell_type": "code",
"execution_count": 9,
"id": "74e78ee1-33b8-4a88-9d23-cd6281603a5b",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"2023-07-07T16:41:31.990-05:00 2023-07-07T10:41:31.990-05:00\n"
]
}
],
"source": [
"print(een.time_now(), een.time_before())"
]
},
{
"cell_type": "code",
"execution_count": 11,
"id": "c84c30dd-4b7c-415b-8e6f-e77de70d1924",
"metadata": {
"scrolled": true
},
"outputs": [],
"source": [
"for i in range(0,4):\n",
" ts = een.time_now()\n",
"\n",
" for cam in een.cameras:\n",
" blah = cam.get_list_of_events(end_timestamp=een.time_before(ts=ts, hours=(6*i)), \\\n",
" start_timestamp=een.time_before(ts=ts, hours=(6*(i+1))) )"
]
},
{
"cell_type": "code",
"execution_count": 12,
"id": "13809cc7-9ec2-4e15-9495-e64feaecca6d",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"18"
]
},
"execution_count": 12,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"len(een.cameras[2].events['status'])"
]
},
{
"cell_type": "code",
"execution_count": 13,
"id": "67d4f79b-2b43-4bdb-9068-28ac9d8d921c",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"0"
]
},
"execution_count": 13,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"len(een.cameras[0].events['motion'])"
]
},
{
"cell_type": "code",
"execution_count": 14,
"id": "8be8d503-b46d-4ba7-884e-2c21c3987129",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"ATM & Wine - 2023-07-07T18:49:29.664+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-07T18:48:51.444+00:00 - error \n",
"ATM & Wine - 2023-07-07T16:22:04.146+00:00 - online \n",
"ATM & Wine - 2023-07-07T16:21:41.960+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-07T16:18:16.383+00:00 - error \n",
"ATM & Wine - 2023-07-07T16:16:31.511+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-07T16:15:57.174+00:00 - error \n",
"ATM & Wine - 2023-07-07T15:44:21.759+00:00 - online \n",
"ATM & Wine - 2023-07-07T15:41:31.997+00:00 - online \n",
"ATM & Wine - 2023-07-07T12:46:47.672+00:00 - bridgeOffline \n",
"ATM & Wine - 2023-07-07T11:24:46.454+00:00 - online \n",
"ATM & Wine - 2023-07-07T11:22:37.461+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-07T11:21:59.127+00:00 - error \n",
"ATM & Wine - 2023-07-07T09:44:24.462+00:00 - online \n",
"ATM & Wine - 2023-07-07T09:41:33.395+00:00 - online \n",
"ATM & Wine - 2023-07-07T06:00:38.322+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-07T06:00:00.175+00:00 - error \n",
"ATM & Wine - 2023-07-07T04:07:20.003+00:00 - online \n",
"ATM & Wine - 2023-07-07T04:05:21.238+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-07T04:04:42.160+00:00 - error \n",
"ATM & Wine - 2023-07-07T03:44:26.904+00:00 - online \n",
"ATM & Wine - 2023-07-07T03:41:56.630+00:00 - online \n",
"ATM & Wine - 2023-07-07T01:48:05.792+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-07T01:47:35.155+00:00 - error \n",
"ATM & Wine - 2023-07-06T23:19:05.335+00:00 - online \n",
"ATM & Wine - 2023-07-06T23:16:58.926+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-06T23:16:28.421+00:00 - error \n",
"ATM & Wine - 2023-07-06T23:10:32.790+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-06T21:44:28.864+00:00 - online \n",
"ATM & Wine - 2023-07-06T21:41:57.860+00:00 - online \n",
"ATM & Wine - 2023-07-06T20:08:47.208+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-06T20:08:16.933+00:00 - error \n",
"ATM & Wine - 2023-07-06T20:03:05.801+00:00 - online \n",
"ATM & Wine - 2023-07-06T20:02:43.659+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-06T20:01:41.457+00:00 - error \n",
"ATM & Wine - 2023-07-06T19:59:57.405+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-06T19:59:24.184+00:00 - error \n",
"ATM & Wine - 2023-07-06T19:31:55.867+00:00 - online \n",
"ATM & Wine - 2023-07-06T19:29:46.708+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-06T19:29:16.013+00:00 - error \n",
"ATM & Wine - 2023-07-06T18:24:27.389+00:00 - online \n",
"ATM & Wine - 2023-07-06T18:22:22.328+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-06T18:21:52.102+00:00 - error \n",
"ATM & Wine - 2023-07-06T15:52:12.759+00:00 - online \n",
"ATM & Wine - 2023-07-06T15:50:09.509+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-06T15:49:39.152+00:00 - error \n",
"ATM & Wine - 2023-07-06T15:41:58.888+00:00 - online \n",
"ATM & Wine - 2023-07-06T15:11:05.317+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-06T15:10:35.056+00:00 - error \n",
"ATM & Wine - 2023-07-06T14:59:51.787+00:00 - online \n",
"ATM & Wine - 2023-07-06T14:58:55.970+00:00 - bridgeOffline \n",
"ATM & Wine - 2023-07-06T14:58:40.967+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-06T12:58:36.884+00:00 - online \n",
"ATM & Wine - 2023-07-06T12:56:38.478+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-06T12:56:07.792+00:00 - error \n",
"ATM & Wine - 2023-07-06T12:49:42.742+00:00 - online \n",
"ATM & Wine - 2023-07-06T12:47:32.312+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-06T12:47:01.925+00:00 - error \n",
"ATM & Wine - 2023-07-06T11:40:03.505+00:00 - online \n",
"ATM & Wine - 2023-07-06T11:38:03.703+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-06T11:37:23.038+00:00 - error \n",
"ATM & Wine - 2023-07-06T11:31:52.611+00:00 - online \n",
"ATM & Wine - 2023-07-06T11:29:42.541+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-06T11:29:08.199+00:00 - error \n",
"ATM & Wine - 2023-07-06T09:42:01.013+00:00 - online \n",
"ATM & Wine - 2023-07-06T09:38:38.302+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-06T09:38:07.937+00:00 - error \n",
"ATM & Wine - 2023-07-06T08:31:02.432+00:00 - online \n",
"ATM & Wine - 2023-07-06T08:28:53.086+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-06T08:28:22.667+00:00 - error \n",
"ATM & Wine - 2023-07-06T04:07:10.931+00:00 - online \n",
"ATM & Wine - 2023-07-06T04:05:11.397+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-06T04:04:38.124+00:00 - error \n",
"ATM & Wine - 2023-07-06T03:43:19.799+00:00 - online \n",
"ATM & Wine - 2023-07-06T02:15:51.335+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-06T02:15:19.584+00:00 - error \n",
"ATM & Wine - 2023-07-05T21:43:39.111+00:00 - online \n",
"ATM & Wine - 2023-07-05T18:35:23.024+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-05T18:34:44.881+00:00 - error \n",
"ATM & Wine - 2023-07-05T17:13:14.356+00:00 - online \n",
"ATM & Wine - 2023-07-05T17:11:09.341+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-05T17:10:29.222+00:00 - error \n",
"ATM & Wine - 2023-07-05T15:43:44.544+00:00 - online \n",
"ATM & Wine - 2023-07-05T15:33:04.629+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-05T15:32:34.148+00:00 - error \n",
"ATM & Wine - 2023-07-05T15:13:36.151+00:00 - online \n",
"ATM & Wine - 2023-07-05T15:11:33.954+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-05T15:10:54.632+00:00 - error \n",
"ATM & Wine - 2023-07-05T14:24:50.082+00:00 - online \n",
"ATM & Wine - 2023-07-05T14:22:50.393+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-05T14:22:12.040+00:00 - error \n",
"ATM & Wine - 2023-07-05T14:02:42.746+00:00 - online \n",
"ATM & Wine - 2023-07-05T14:00:34.085+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-05T13:59:55.709+00:00 - error \n",
"ATM & Wine - 2023-07-05T10:43:54.157+00:00 - online \n",
"ATM & Wine - 2023-07-05T10:41:56.025+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-05T10:41:23.556+00:00 - error \n",
"ATM & Wine - 2023-07-05T10:10:27.404+00:00 - online \n",
"ATM & Wine - 2023-07-05T10:08:19.943+00:00 - deviceOffline \n",
"ATM & Wine - 2023-07-05T10:07:50.455+00:00 - error \n",
"ATM & Wine - 2023-07-05T09:44:11.782+00:00 - online \n",
"Cash Register - 2023-07-07T20:02:49.237+00:00 - bridgeOffline \n",
"Cash Register - 2023-07-07T20:02:31.354+00:00 - deviceOffline \n",
"Cash Register - 2023-07-07T16:01:54.202+00:00 - online \n",
"Cash Register - 2023-07-07T16:01:52.891+00:00 - bridgeOffline \n",
"Cash Register - 2023-07-07T15:44:21.759+00:00 - online \n",
"Cash Register - 2023-07-07T15:41:31.997+00:00 - online \n",
"Cash Register - 2023-07-06T23:01:59.550+00:00 - bridgeOffline \n",
"Cash Register - 2023-07-06T21:44:28.864+00:00 - online \n",
"Cash Register - 2023-07-06T21:41:57.860+00:00 - online \n",
"Cash Register - 2023-07-06T14:58:55.385+00:00 - bridgeOffline \n",
"Cash Register - 2023-07-06T14:58:40.382+00:00 - deviceOffline \n",
"Cash Register - 2023-07-06T10:01:40.245+00:00 - online \n",
"Cash Register - 2023-07-06T10:01:39.158+00:00 - bridgeOffline \n",
"Cash Register - 2023-07-06T09:42:01.013+00:00 - online \n",
"Cash Register - 2023-07-05T20:06:43.349+00:00 - bridgeOffline \n",
"Cash Register - 2023-07-05T18:01:54.584+00:00 - online \n",
"Cash Register - 2023-07-05T18:01:53.582+00:00 - bridgeOffline \n",
"Cash Register - 2023-07-05T15:43:44.544+00:00 - online \n",
"Safe - 2023-07-06T14:58:55.537+00:00 - bridgeOffline \n",
"Safe - 2023-07-06T14:58:40.535+00:00 - deviceOffline \n",
"Safe - 2023-07-06T09:42:01.013+00:00 - online \n",
"Safe - 2023-07-06T07:27:05.695+00:00 - bridgeOffline \n",
"Safe - 2023-07-06T03:43:19.799+00:00 - online \n",
"Safe - 2023-07-06T02:05:12.252+00:00 - bridgeOffline \n",
"Safe - 2023-07-05T21:43:39.111+00:00 - online \n"
]
}
],
"source": [
"for cam in een.cameras:\n",
" for i in cam.events['status']:\n",
" print(f\"{cam.name} - {i['startTimestamp']} - {i['data'][0]['newStatus']['connectionStatus']} \")"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "1cebbb4e-4c4e-4ab0-9627-251eb812b2f1",
"metadata": {},
"outputs": [],
"source": []
},
{
"cell_type": "code",
"execution_count": null,
"id": "d8a69185-54cc-4c74-9c9b-a807e601be83",
"metadata": {},
"outputs": [],
"source": []
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.11.3"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View File

@ -1,15 +1,40 @@
# EagleEyev3 #
# EE-status-v3 #
## Summary ##
This is a python package for working with the Eagle Eye Networks v3 API. It is all about syntatical sugar and making things a little nicer to work with.
This is a python webapp for getting status history for your cameras.
## Settings File ##
There is file `settings.py` that is needed to run. Please reach out to api_support@een.com to get the necessary credentials.
There is file `settings.py` that is needed to run. It should look similar to this:
```
config = {
# Set up your application and get client id/secrete first
# https://developerv3.eagleeyenetworks.com/page/my-application
"client_id": "",
"client_secret": "",
# you will need to add approved redirect_uris in your application
# this examples assumes you've added http://127.0.0.1:3333/login_callback
# change the following variables if you did something different
# Note: do not use localhost for server_host, use 127.0.0.1 instead
"server_protocol": "http",
"server_host": "127.0.0.1",
"server_port": "3333",
"server_path": "login_callback",
# preferences
"days_of_history": 1,
"log_level": "INFO"
}
```
You can create your application and setup credentials at: [https://developerv3.eagleeyenetworks.com/page/my-application-html](my applications). You can also reach out to api_support@een.com for help.
## Ideas on how to use ##
I encluded an example Flask server in `server.py`. This shows how to wire it up to a login route and handle the callback.
I encluded an example Flask server in `app.py`. You can run it locally with `python flask run -p 3333 --debug`
You can also use this in a script by running in iPython in interactive mode `ipython -i server.py`. After you complete to login process, you can hit `CTRL-C` to stop the Flask server. You can then look at the `een` object to get all the instance variables.
There is also a `Playground.ipynb` that you can run in Jupyter Notebook.
It uses server sessions to track your instance of the EagleEyev3 class but doesn't have any other storage. It uses the API as the source of truth.

315
app.py
View File

@ -3,62 +3,164 @@ import json, requests
from flask import Flask, request, session, render_template, redirect, url_for, Response, send_file
from flask_session import Session
import pandas as pd
import numpy as np
from matplotlib.figure import Figure
import matplotlib.dates as mdates
import base64
from tqdm import tqdm
from io import BytesIO
import logging
logger = logging.getLogger()
#logger.setLevel('DEBUG')
logger.setLevel('INFO')
#logger.setLevel('WARN')
#logger.setLevel('ERROR')
#logger.setLevel('CRITICAL')
from functools import wraps
from EagleEyev3 import EagleEyev3
from EagleEyev3 import *
from settings import config
SECRET_KEY = "this needs to be changed to something unique and not checked into git"
# check if it could pull in a config object from settings.py
if config:
# start checking for keys in config object and set sensible defaults
if 'days_of_history' in config:
DAYS_OF_HISTORY = config['days_of_history']
else:
# fallback to a sane default
DAYS_OF_HISTORY = 1
if 'log_level' in config:
logger.setLevel(config['log_level'])
if 'client_secret' in config:
SECRET_KEY = config['client_secret']
else:
logging.setLevel(config['INFO'])
logging.info(f"Using EagleEyev3 version {EagleEyev3.__version__}")
app = Flask(__name__)
app.secret_key = SECRET_KEY
SESSION_TYPE = 'filesystem'
SESSION_PERMANENT = False
PERMANENT_SESSION_LIFETIME = 60 * 60 * 24 * 7 # one week in seconds
app.config.from_object(__name__)
Session(app)
@app.route('/')
def index():
# $ flask routes -s rule
# INFO:root:Using EagleEyev3 version 0.0.15
# Endpoint Methods Rule
# --------------------- ------- -------------------------------
# index GET /
# accounts GET /accounts
# camera_detail GET /camera/<esn>/events
# camera_detail GET /camera/<esn>/events/<int:days>
# camera_live_preivew GET /camera/<esn>/preview
# camera__preivew_image GET /camera/<esn>/preview_image
# camera_status_plot GET /camera/<esn>/status_plot
# cameras GET /cameras
# landing GET /landing
# login_callback GET /login_callback
# logout GET /logout
# static GET /static/<path:filename>
# switch_account GET /switch_account
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
logging.debug(session)
if 'een' in session and session['een'].access_token:
logging.debug(f"@login_required access_token: {session['een'].access_token}")
return f(*args, **kwargs)
else:
# failed to find a valid session so redirecting to landing page
if 'een' in session:
logging.debug(f"@login_required access_token: {session['een'].access_token}")
else:
logging.warn('@login_requried failed to find a valid session')
return redirect(url_for('landing'))
return decorated_function
@app.route('/landing')
def landing():
# sometimes you just want to get to the landing page and want to avoid the index redirect logic
if 'een' in session:
een = session['een']
else:
een = EagleEyev3()
een = EagleEyev3(config)
session['een'] = een
base_url = "https://auth.eagleeyenetworks.com/oauth2/authorize"
path_url = f"?client_id={een.client_id}&response_type=code&scope=vms.all&redirect_uri={een.redirect_uri}"
values = { "login_link": f"{base_url}{path_url}" }
return render_template('cover.html', template_values=values)
@app.route('/')
@login_required
def index():
# get een instance from session, @login_required already checked for it
een = session['een']
# using current_user as a proxy for an established valid session
if een.access_token == None:
base_url = "https://auth.eagleeyenetworks.com/oauth2/authorize"
path_url = f"?client_id={een.client_id}&response_type=code&scope=vms.all&redirect_uri={een.redirect_uri}"
return redirect(f"{base_url}{path_url}")
values = { "login_link": f"{base_url}{path_url}" }
return render_template('cover.html', template_values=values)
# call this to check if session is actually valid
check = een.get_current_user()
if 'success' not in check or check['success'] == False:
base_url = "https://auth.eagleeyenetworks.com/oauth2/authorize"
path_url = f"?client_id={een.client_id}&response_type=code&scope=vms.all&redirect_uri={een.redirect_uri}"
return redirect(f"{base_url}{path_url}")
values = { "login_link": f"{base_url}{path_url}" }
return render_template('cover.html', template_values=values)
else:
logging.info(f"{check['success']} - check get_current_user")
logging.info(f"{check['success']} - check get_current_user {een.current_user['email']} {een.access_token}")
#logging.info(een.cameras)
# this call could get expensive
een.get_list_of_cameras()
een.get_list_of_accounts()
#logging.info(een.cameras)
if len(een.accounts) > 0 and een.active_account == None:
# they need to pick and account
logging.info(f"redirecting to accounts.html because they don't have an active account { een.active_account }")
values = {
"current_user": een.current_user,
"cameras": een.cameras
"accounts": een.accounts,
"active_account": een.active_account
}
redirect(url_for('accounts'))
values = {
"current_user": een.current_user,
"cameras": een.cameras,
"camera_count": len(een.cameras),
"camera_count_online": len([i for i in een.cameras if i.is_online()]),
"camera_count_offline": len([i for i in een.cameras if i.is_offline()]),
"accounts": een.accounts,
"active_account": een.active_account
}
return render_template('index.html', template_values=values)
@ -66,55 +168,103 @@ def index():
@app.route('/login_callback')
def login_callback():
# This is getting the ?code= querystring value from the HTTP request.
code = request.args.get('code')
if 'een' in session:
een = session['een']
else:
een = EagleEyev3()
# create a new een object and store it in their session
een = EagleEyev3(config)
session['een'] = een
if (code):
# use the include code parameter to complete login process
oauth_object = een.login_tokens(code)
logging.debug(oauth_object)
# let's try resaving this to see if it fixs the missing access_token on first request after login
session['een'] = een
return redirect(url_for('index'))
@app.route('/logout')
def logout():
if 'een' in session:
een = session['een']
een.logout()
# logout isn't working APIv3 right now so don't wait for the call to fail before logging out
# if 'een' in session:
# een = session['een']
# een.logout()
session.pop('een')
return redirect(url_for('index'))
@app.route('/cameras')
@login_required
def cameras():
if 'een' in session:
een = session['een']
else:
een = EagleEyev3()
een.get_list_of_cameras()
# get een instance from session, @login_required already checked for it
een = session['een']
logging.debug(een.get_list_of_cameras())
values = {
"current_user": een.current_user,
"cameras": een.cameras
"cameras": een.cameras,
"camera_count": len(een.cameras),
"camera_count_online": len([i for i in een.cameras if i.is_online()]),
"camera_count_offline": len([i for i in een.cameras if i.is_offline()])
}
return render_template('cameras_partial.html', template_values=values)
@app.route('/camera/<esn>/live_preview')
def camera_live_preivew(esn=None):
if 'een' in session:
@app.route('/accounts')
@login_required
def accounts():
# get een instance from session, @login_required already checked for it
een = session['een']
logging.debug(een.get_list_of_accounts())
values = {
"current_user": een.current_user,
"accounts": een.accounts,
"active_account": een.active_account
}
return render_template('accounts_partial.html', template_values=values)
@app.route('/switch_account')
@login_required
def switch_account():
# This is getting the ?account= querystring value from the HTTP request.
account = request.args.get('account')
# get een instance from session, @login_required already checked for it
een = session['een']
if (account):
# switch into account
logging.info(f"attempting to switch into account {account}")
logging.debug(een.login_from_reseller(target_account_id=account))
return redirect(url_for('index'))
@app.route('/camera/<esn>/preview_image')
@login_required
def camera__preivew_image(esn=None):
# get een instance from session, @login_required already checked for it
een = session['een']
else:
een = EagleEyev3()
camera = een.get_camera_by_id(esn)
res = camera.get_live_preview()
@ -125,16 +275,14 @@ def camera_live_preivew(esn=None):
return send_file('static/placeholder.png')
@app.route('/camera/<esn>')
def camera_detail(esn=None):
if 'een' in session:
@app.route('/camera/<esn>/preview')
@login_required
def camera_live_preivew(esn=None):
# get een instance from session, @login_required already checked for it
een = session['een']
else:
een = EagleEyev3()
camera = een.get_camera_by_id(esn)
camera.get_list_of_events(end_timestamp=een.time_before(ts=een.time_now(), hours=0), \
start_timestamp=een.time_before(ts=een.time_now(), hours=6) )
values = {
"current_user": een.current_user,
@ -142,7 +290,84 @@ def camera_detail(esn=None):
"events": camera.events['status']
}
return render_template('camera_detail_partial.html', template_values=values)
return render_template('camera_preview.html', template_values=values)
@app.route("/camera/<esn>/events/<int:days>")
@app.route('/camera/<esn>/events')
@login_required
def camera_detail(esn=None, days=DAYS_OF_HISTORY):
# get een instance from session, @login_required already checked for it
een = session['een']
camera = een.get_camera_by_id(esn)
# because of API limitation, can only query 24 hours max
for i in tqdm(range(0, days)):
camera.get_list_of_events(end_timestamp=een.time_before(hours=24*i), \
start_timestamp=een.time_before(hours=24*(i+1)))
values = {
"current_user": een.current_user,
"camera": camera,
"events": camera.events['status']
}
return render_template('camera_events_partial.html', template_values=values)
@app.route('/camera/<esn>/status_plot')
@login_required
def camera_status_plot(esn=None):
# get een instance from session, @login_required already checked for it
een = session['een']
cam = een.get_camera_by_id(esn)
logging.debug(cam.events['status'][0])
if cam.events['status'][0]['endTimestamp'] == None:
logging.debug('found empty end_timestamp')
cam.events['status'][0]['endTimestamp'] = str(pd.Timestamp.utcnow())
atm_df = pd.DataFrame(cam.events['status'][::-1], columns=['id', 'startTimestamp', 'actorId', 'data'])
atm_df['ts'] = pd.to_datetime(atm_df.startTimestamp)
atm_df['status_desc'] = atm_df['data'].apply(lambda x: x[0]['newStatus']['connectionStatus'])
atm_df['status'] = atm_df['status_desc'].replace(to_replace=['online', 'offline', 'error', 'deviceOffline', 'deviceOnline', 'off', 'bridgeOffline', 'unknown'], value=[1,0,0,0,0,0,0,0])
imp = atm_df.set_index(['ts'])
imp['startTimestamp'] = pd.to_datetime(imp['startTimestamp'])
imp = imp.drop(['id', 'actorId', 'data', 'status_desc'], axis=1)
imp['status'] = imp['status'].ffill()
data = imp.resample('S').ffill()
# logging.debug(data.tail(200))
# data['status'] = data['status'].astype('int64')
data = data.drop(['startTimestamp'], axis=1)
# Generate the figure **without using pyplot**.
fig = Figure(figsize=(6, 5), dpi=160)
ax = fig.subplots()
ax.step(data.index, data['status'], lw=2, color='blue')
ax.set_title(cam.name)
# ax.fill_between(data['startTimestamp'], data['status'])
ax.axhline(1, color='green', lw=2, alpha=0.3)
ax.axhline(0, color='red', lw=2, alpha=0.3)
for label in ax.get_xticklabels(which='major'):
label.set(rotation=30, horizontalalignment='right')
# Save it to a temporary buffer.
buf = BytesIO()
fig.savefig(buf, format="png", transparent=True)
# Embed the result in the html output.
data = base64.b64encode(buf.getbuffer()).decode("ascii")
return f"<h3>Graph of Status Events <i class='bi bi-bar-chart'></i></h3><div class='col-md-12'><img src='data:image/png;base64,{data}' style='max-width:100%'/></div>"
@ -151,4 +376,4 @@ def camera_detail(esn=None):
if __name__ == '__main__':
app.run(host=een.server_host, port=een.server_port)
app.run(host=config.server_host, port=config.server_port)

13
docker-compose.yaml Normal file
View File

@ -0,0 +1,13 @@
version: '3'
services:
app:
build: .
volumes:
- .:/app
ports:
- "9300:3000"
restart: always
tty: true
user: 1000:1000

View File

@ -9,7 +9,13 @@ idna==3.4
itsdangerous==2.1.2
Jinja2==3.1.2
MarkupSafe==2.1.3
pytz==2023.3
requests==2.29.0
urllib3==1.26.16
Werkzeug==2.3.6
gunicorn==20.1.0
cachelib==0.10.2
Flask-Session==0.5.0
EagleEyev3>=0.0.18
tqdm
pandas
numpy
matplotlib

4
startup.sh Executable file
View File

@ -0,0 +1,4 @@
#!/bin/bash
#python3 -u server.py
gunicorn --bind 0.0.0.0:3000 --limit-request-line 0 --worker-class=gthread --threads=4 wsgi:app

7
static/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

7
static/bootstrap.min.js vendored Normal file

File diff suppressed because one or more lines are too long

106
static/cover.css Normal file
View File

@ -0,0 +1,106 @@
/*
* Globals
*/
/* Links */
a,
a:focus,
a:hover {
color: #fff;
}
/* Custom default button */
.btn-secondary,
.btn-secondary:hover,
.btn-secondary:focus {
color: #333;
text-shadow: none; /* Prevent inheritance from `body` */
background-color: #fff;
border: .05rem solid #fff;
}
/*
* Base structure
*/
html,
body {
height: 100%;
background-color: #333;
}
body {
display: -ms-flexbox;
display: flex;
color: #fff;
text-shadow: 0 .05rem .1rem rgba(0, 0, 0, .5);
box-shadow: inset 0 0 5rem rgba(0, 0, 0, .5);
}
.cover-container {
max-width: 42em;
}
/*
* Header
*/
.masthead {
margin-bottom: 2rem;
}
.masthead-brand {
margin-bottom: 0;
}
.nav-masthead .nav-link {
padding: .25rem 0;
font-weight: 700;
color: rgba(255, 255, 255, .5);
background-color: transparent;
border-bottom: .25rem solid transparent;
}
.nav-masthead .nav-link:hover,
.nav-masthead .nav-link:focus {
border-bottom-color: rgba(255, 255, 255, .25);
}
.nav-masthead .nav-link + .nav-link {
margin-left: 1rem;
}
.nav-masthead .active {
color: #fff;
border-bottom-color: #fff;
}
@media (min-width: 48em) {
.masthead-brand {
float: left;
}
.nav-masthead {
float: right;
}
}
/*
* Cover
*/
.cover {
padding: 0 1.5rem;
}
.cover .btn-lg {
padding: .75rem 1.25rem;
font-weight: 700;
}
/*
* Footer
*/
.mastfoot {
color: rgba(255, 255, 255, .5);
}

BIN
static/cover_movie.mp4 Normal file

Binary file not shown.

BIN
static/cover_movie_2.mp4 Normal file

Binary file not shown.

5528
static/theme.css Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,27 @@
<div class="row">
<div class="col-4 offset-5">
<h2>
{% for account in template_values['accounts'] %}
{% if account.id == template_values['active_account'] %}
{{ account.name }}
{% endif %}
{% endfor %}
</h2>
</div>
<div class="col-3">
<label >Choose Account:</label>
<select onchange="this.options[this.selectedIndex].value && (window.location = this.options[this.selectedIndex].value);">
{% for account in template_values['accounts'] %}
<option value="/switch_account?account={{ account.id }}"
{% if account.id == template_values['active_account'] %}
selected
{% endif %}
title="{{ account.id }}">
{{ account.name }}
</option>
{% endfor %}
</select>
</div>
</div>

View File

@ -26,6 +26,22 @@
<!-- self-hosted fontawesome -->
<link href="/static/css/all.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
<link href="/static/theme.css" rel="stylesheet">
<style>
.row {
border: dashed lightgray 1px;
}
</style>
<style>
.left_text {
text-align: left;
}
</style>
{% block style %}
{% endblock %}
@ -43,7 +59,7 @@
<ul class="navbar-nav mr-auto mt-2 mt-lg-0">
<li class="nav-item active">
<a href="/" class="navbar-brand d-flex align-items-center">
<strong><small>status</small>.mcotton.space</strong>
<small>status</small>.mcotton.space
</a>
</li>

View File

@ -1,24 +1,3 @@
<div class="row">
<div class="col-md-6">
<h3>{{ template_values['camera'].name }}</h3>
<img src="/camera/{{ template_values['camera'].id }}/live_preview" style="width: 100%; max-height:360px;">
</div>
<div class="col-md-6" id="events_list">
<h3>Events</h3>
<ul>
{% if template_values['events'] %}
{% for event in template_values['events'] %}
<li>{{ event['data'][0]['newStatus']['connectionStatus'] }} <br> <small>{{ event['startTimestamp'] }}</small></li>
{% endfor %}
{% else %}
<li>No events in the last six hours</li>
{% endif %}
</ul>
<div>
<!-- <button hx-get="/camera/{{ template_values['camera'].id }}/events" hx-trigger="click" hx-target="#event_list">refresh</button> -->
</div>
</div>
</div>
<h3>Preview Image <i class="bi bi-card-image"></i></h3>
<h5>{{ template_values['camera'].name }}</h5>
<img src="/camera/{{ template_values['camera'].id }}/live_preview" style="max-height:360px;">

View File

@ -0,0 +1,46 @@
<h3>List of Events <i class="bi bi-calendar-event"></i></h3>
<h5>{{ template_values['camera'].name }}</h5>
<a href="/camera/{{ template_values['camera'].id }}/events/3" hx-get="/camera/{{ template_values['camera'].id }}/events/3" hx-trigger="click" hx-target="#camera_status_events" hx-indicator=".progress">
<button class="btn btn-outline-success">
3x<i class="bi bi-calendar-event" title="click to load events list for 3 days"></i>
</button>
</a>
<a href="/camera/{{ template_values['camera'].id }}/events/7" hx-get="/camera/{{ template_values['camera'].id }}/events/7" hx-trigger="click" hx-target="#camera_status_events" hx-indicator=".progress">
<button class="btn btn-outline-success">
7x<i class="bi bi-calendar-event" title="click to load events list for 7 days"></i>
</button>
</a>
<a href="/camera/{{ template_values['camera'].id }}/events/14" hx-get="/camera/{{ template_values['camera'].id }}/events/14" hx-trigger="click" hx-target="#camera_status_events" hx-indicator=".progress">
<button class="btn btn-outline-success">
14x<i class="bi bi-calendar-event" title="click to load events list for 14 days"></i>
</button>
</a>
<a href="/camera/{{ template_values['camera'].id }}/status_plot" hx-get="/camera/{{ template_values['camera'].id }}/status_plot" hx-trigger="click" hx-target="#camera_status_plot" hx-indicator=".progress">
<button class="btn btn-outline-success">
Graph Events <i class="bi bi-bar-chart" title="click to generate graph of events"></i>
</button>
</a>
{% if template_values['events'] %}
{% for event in template_values['events'] %}
<div class="row">
<div class="col-2 left_text">
<small>Status:</small>
</div>
<div class="col-9 offset-1 left_text">
<b>{{ event['data'][0]['newStatus']['connectionStatus'] }}</b>
</div>
<div class="col-2 left_text">
<small>End:</small>
</div>
<div class="col-9 offset-1 left_text">
{{ event['endTimestamp'] }}
</div>
<div class="col-2 left_text">
<small>Start:</small>
</div>
<div class="col-9 offset-1 left_text">
{{ event['startTimestamp'] }}
</div>
</div>
{% endfor %}
{% endif %}

View File

@ -0,0 +1,3 @@
<h3>Preview Image <i class="bi bi-card-image"></i></h3>
<h5>{{ template_values['camera'].name }}</h5>
<img src="/camera/{{ template_values['camera'].id }}/preview_image" style="max-height:360px; max-width:100%">

View File

@ -1,28 +1,61 @@
<div class="col-md-3" id="camera_list_items">
<h3>Online</h3>
<ul>
<h3>Cameras <i class="bi bi-camera-video"></i></h3>
<h5>Online <small>[{{ template_values['camera_count_online'] }} of {{ template_values['camera_count'] }}]</small></h5>
{% for camera in template_values['cameras'] %}
{% if camera.is_online() %}
<li hx-get="/camera/{{ camera.id }}" hx-trigger="click" hx-target="#camera_detail"> {{ camera.name }}</li>
<div class="row">
<div class="col-sm-5 col-md-6 offset-1 left_text" style="overflow: hidden;">
<span title="ESN: {{ camera.id }}">{{ camera.name }}</span>
</div>
<div class="col-sm-6 col-md-5">
<a href="/camera/{{ camera.id }}/preview" hx-get="/camera/{{ camera.id }}/preview" hx-trigger="click" hx-target="#camera_detail" hx-indicator=".progress">
<button class="btn btn-outline-success">
<i class="bi bi-card-image" title="click to load preview image"></i>
</button>
</a>
<a href="/camera/{{ camera.id }}/events" hx-get="/camera/{{ camera.id }}/events" hx-trigger="click" hx-target="#camera_status_events" hx-indicator=".progress">
<button class="btn btn-outline-success">
<i class="bi bi-calendar-event" title="click to load events list"></i>
</button>
</a>
<!-- <a href="/camera/{{ camera.id }}/status_plot" hx-get="/camera/{{ camera.id }}/status_plot" hx-trigger="click" hx-target="#camera_status_plot" hx-indicator=".progress">
<button class="btn btn-outline-success">
<i class="bi bi-bar-chart" title="click to generate graph of events"></i>
</button>
</a> -->
</div>
</div>
{% endif %}
{% endfor %}
</ul>
<h3>Offline</h3>
<ul>
<h5>Offline <small>[{{ template_values['camera_count_offline'] }} of {{ template_values['camera_count'] }}]</small></h5>
{% for camera in template_values['cameras'] %}
{% if not camera.is_online() %}
<li hx-get="/camera/{{ camera.id }}" hx-trigger="click" hx-target="#camera_detail"> {{ camera.name }}</li>
{% if camera.is_offline() %}
<div class="row">
<div class="col-sm-5 col-md-6 offset-1 left_text" style="overflow: hidden;">
<span title="ESN: {{ camera.id }} Status: {{ camera.status['connectionStatus'] }}">{{ camera.name }}</span>
</div>
<div class="col-sm-6 col-md-5">
<a href="/camera/{{ camera.id }}/preview" hx-get="/camera/{{ camera.id }}/preview" hx-trigger="click" hx-target="#camera_detail" hx-indicator=".progress">
<button class="btn btn-outline-success">
<i class="bi bi-card-image" title="click to load preview image"></i>
</button>
</a>
<a href="/camera/{{ camera.id }}/events" hx-get="/camera/{{ camera.id }}/events" hx-trigger="click" hx-target="#camera_status_events" hx-indicator=".progress">
<button class="btn btn-outline-success">
<i class="bi bi-calendar-event" title="click to load events list"></i>
</button>
</a>
<!-- <a href="/camera/{{ camera.id }}/status_plot" hx-get="/camera/{{ camera.id }}/status_plot" hx-trigger="click" hx-target="#camera_status_plot" hx-indicator=".progress">
<button class="btn btn-outline-success">
<i class="bi bi-bar-chart" title="click to generate graph of events"></i>
</button>
</a> -->
</div>
</div>
{% endif %}
{% endfor %}
</ul>
<div class="col-md-6 offset-3">
<button hx-get="/cameras" hx-trigger="click" hx-target="#camera_list">refresh</button>
</div>
</div>
<div class="col-md-9" id="camera_detail"></div>
<button hx-get="/cameras" hx-trigger="click" hx-target="#camera_list" class="btn btn-md btn-outline-success" hx-indicator=".progress">refresh <i class="bi bi-arrow-clockwise"></i></button>
<br>

61
templates/cover.html Normal file
View File

@ -0,0 +1,61 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="">
<meta name="author" content="">
<link rel="icon" href="/static/favicon.ico">
<title>status.mcotton.space</title>
<!-- Bootstrap core CSS -->
<link href="/static/bootstrap.min.css" rel="stylesheet">
<!-- Custom styles for this template -->
<link href="/static/cover.css" rel="stylesheet">
</head>
<body class="text-center">
<div class="cover-container d-flex w-100 h-100 p-3 mx-auto flex-column">
<header class="masthead mb-auto">
<div class="inner">
<h3 class="masthead-brand"><a href="/"><small>status</small>.mcotton.space</a></h3>
<!-- <nav class="nav nav-masthead justify-content-center">
<a class="nav-link active" href="#">Home</a>
<a class="nav-link" href="#">Features</a>
<a class="nav-link" href="#">Contact</a>
</nav>
</div> -->
</header>
<main role="main" class="inner cover">
<h1 class="cover-heading">Camera Status History</h1>
<p class="lead">Tired of losing your arms due to offline cameras? Tired of doughnuts being stolen from your breakfast plate? Wish you could see a graph of beeps and boops? Today is your lucky day.</p>
<p class="lead">
<a href="{{ template_values['login_link'] }}" class="btn btn-lg btn-secondary">Get Started</a>
</p>
<hr>
<video src="/static/cover_movie_2.mp4" style="max-width:100%;" autoplay controls loop muted preload >
</main>
<footer class="mastfoot mt-auto">
<div class="inner">
<p>This project is open-source and can be found at <a href="https://git.mcotton.space/mcotton/EE-status-v3">EE-status-v3</a> and uses <a href="https://pypi.org/project/EagleEyev3/">EagleEyev3</a>.</p>
</div>
</footer>
</div>
<!-- Bootstrap core JavaScript
================================================== -->
<!-- Placed at the end of the document so the pages load faster -->
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
<script>window.jQuery || document.write('<script src="../../assets/js/vendor/jquery-slim.min.js"><\/script>')</script>
<script src="../../assets/js/vendor/popper.min.js"></script>
<script src="/static/bootstrap.min.js"></script>
</body>
</html>

View File

@ -12,6 +12,110 @@
overflow: auto;
}
.progress {
position: fixed;
top: 0;
z-index: 1000;
height: 4px;
width: 100%;
border-radius: 2px;
background-clip: padding-box;
overflow: hidden;
}
.progress .indeterminate:before {
content: "";
position: absolute;
background-color: inherit;
top: 0;
left: 0;
bottom: 0;
will-change: left, right;
-webkit-animation: indeterminate 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395)
infinite;
animation: indeterminate 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395) infinite;
}
.progress .indeterminate:after {
content: "";
position: absolute;
background-color: inherit;
top: 0;
left: 0;
bottom: 0;
will-change: left, right;
-webkit-animation: indeterminate-short 2.1s cubic-bezier(0.165, 0.84, 0.44, 1)
infinite;
animation: indeterminate-short 2.1s cubic-bezier(0.165, 0.84, 0.44, 1)
infinite;
-webkit-animation-delay: 1.15s;
animation-delay: 1.15s;
}
.progress {
display: none;
}
.htmx-request .progress {
display: inline;
}
.htmx-request.progress {
display: inline;
}
@-webkit-keyframes indeterminate {
0% {
left: -35%;
right: 100%;
}
60% {
left: 100%;
right: -90%;
}
100% {
left: 100%;
right: -90%;
}
}
@keyframes indeterminate {
0% {
left: -35%;
right: 100%;
}
60% {
left: 100%;
right: -90%;
}
100% {
left: 100%;
right: -90%;
}
}
@-webkit-keyframes indeterminate-short {
0% {
left: -200%;
right: 100%;
}
60% {
left: 107%;
right: -8%;
}
100% {
left: 107%;
right: -8%;
}
}
@keyframes indeterminate-short {
0% {
left: -200%;
right: 100%;
}
60% {
left: 107%;
right: -8%;
}
100% {
left: 107%;
right: -8%;
}
}
</style>
{% endblock %}
@ -22,11 +126,33 @@
{% block main %}
{% if template_values['accounts']|count > 1 %}
{% include 'accounts_partial.html' %}
{% endif %}
<div class="progress" style="height: 3px; background-color: white;">
<div class="indeterminate" style="background-color: red;"></div>
</div>
<div class="container text-center">
<div class="row" id="camera_list">
<div class="row">
<div class="col-md-5" id="camera_list" style="max-height:400px; overflow:auto;">
{% include 'cameras_partial.html' %}
</div>
<div class="col-md-7" id="camera_detail">
<h2>Preview Image <i class="bi bi-card-image"></i></h2>
</div>
</div>
<br>
<div class="row">
<div class="col-md-5" id="camera_status_events" style="max-height:400px; overflow:auto;">
<h2>List of Events <i class="bi bi-calendar-event"></i></h2>
</div>
<div class="col-md-7" id="camera_status_plot">
<h2>Graph of Status Events <i class="bi bi-bar-chart"></i></h2>
</div>
</div>
</div>

4
wsgi.py Normal file
View File

@ -0,0 +1,4 @@
from app import app
if __name__ == "__main__":
app.run()