Compare commits

..

No commits in common. "main" and "full_example" have entirely different histories.

26 changed files with 1223 additions and 6363 deletions

2
.gitignore vendored
View File

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

View File

@ -1,15 +0,0 @@
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" ]

641
EagleEyev3/__init__.py Normal file
View File

@ -0,0 +1,641 @@
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

14
EagleEyev3/settings.py Normal file
View File

@ -0,0 +1,14 @@
# 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
View File

@ -1,21 +0,0 @@
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.

459
Playground.ipynb Normal file
View File

@ -0,0 +1,459 @@
{
"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,40 +1,15 @@
# EE-status-v3 # # EagleEyev3 #
## Summary ## ## Summary ##
This is a python webapp for getting status history for your cameras. 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.
## Settings File ## ## Settings File ##
There is file `settings.py` that is needed to run. It should look similar to this: There is file `settings.py` that is needed to run. Please reach out to api_support@een.com to get the necessary credentials.
```
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 ## ## Ideas on how to use ##
I encluded an example Flask server in `app.py`. You can run it locally with `python flask run -p 3333 --debug` I encluded an example Flask server in `server.py`. This shows how to wire it up to a login route and handle the callback.
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. 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.

315
app.py
View File

@ -3,164 +3,62 @@ import json, requests
from flask import Flask, request, session, render_template, redirect, url_for, Response, send_file from flask import Flask, request, session, render_template, redirect, url_for, Response, send_file
from flask_session import Session 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 from io import BytesIO
import logging import logging
logger = logging.getLogger() logger = logging.getLogger()
from functools import wraps #logger.setLevel('DEBUG')
logger.setLevel('INFO')
#logger.setLevel('WARN')
#logger.setLevel('ERROR')
#logger.setLevel('CRITICAL')
from EagleEyev3 import * from EagleEyev3 import EagleEyev3
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 = Flask(__name__)
app.secret_key = SECRET_KEY
SESSION_TYPE = 'filesystem' SESSION_TYPE = 'filesystem'
SESSION_PERMANENT = False
PERMANENT_SESSION_LIFETIME = 60 * 60 * 24 * 7 # one week in seconds
app.config.from_object(__name__) app.config.from_object(__name__)
Session(app) Session(app)
# $ 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(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('/') @app.route('/')
@login_required
def index(): def index():
# get een instance from session, @login_required already checked for it if 'een' in session:
een = session['een'] een = session['een']
else:
een = EagleEyev3()
session['een'] = een
# using current_user as a proxy for an established valid session # using current_user as a proxy for an established valid session
if een.access_token == None: if een.access_token == None:
base_url = "https://auth.eagleeyenetworks.com/oauth2/authorize" 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}" 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 redirect(f"{base_url}{path_url}")
return render_template('cover.html', template_values=values)
# call this to check if session is actually valid # call this to check if session is actually valid
check = een.get_current_user() check = een.get_current_user()
if 'success' not in check or check['success'] == False: if 'success' not in check or check['success'] == False:
base_url = "https://auth.eagleeyenetworks.com/oauth2/authorize" 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}" 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: else:
logging.info(f"{check['success']} - check get_current_user {een.current_user['email']} {een.access_token}") logging.info(f"{check['success']} - check get_current_user")
#logging.info(een.cameras)
# this call could get expensive
een.get_list_of_cameras() een.get_list_of_cameras()
een.get_list_of_accounts()
if len(een.accounts) > 0 and een.active_account == None: #logging.info(een.cameras)
# 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 = { values = {
"current_user": een.current_user, "current_user": een.current_user,
"accounts": een.accounts, "cameras": een.cameras
"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) return render_template('index.html', template_values=values)
@ -168,103 +66,55 @@ def index():
@app.route('/login_callback') @app.route('/login_callback')
def login_callback(): def login_callback():
# This is getting the ?code= querystring value from the HTTP request. # This is getting the ?code= querystring value from the HTTP request.
code = request.args.get('code') code = request.args.get('code')
# create a new een object and store it in their session if 'een' in session:
een = EagleEyev3(config) een = session['een']
session['een'] = een else:
een = EagleEyev3()
if (code): if (code):
# use the include code parameter to complete login process # use the include code parameter to complete login process
oauth_object = een.login_tokens(code) 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')) return redirect(url_for('index'))
@app.route('/logout') @app.route('/logout')
def logout(): def logout():
if 'een' in session:
# logout isn't working APIv3 right now so don't wait for the call to fail before logging out een = session['een']
# if 'een' in session: een.logout()
# een = session['een']
# een.logout()
session.pop('een')
return redirect(url_for('index')) return redirect(url_for('index'))
@app.route('/cameras') @app.route('/cameras')
@login_required
def cameras(): def cameras():
if 'een' in session:
# get een instance from session, @login_required already checked for it
een = session['een'] een = session['een']
else:
een = EagleEyev3()
logging.debug(een.get_list_of_cameras()) een.get_list_of_cameras()
values = { values = {
"current_user": een.current_user, "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) return render_template('cameras_partial.html', template_values=values)
@app.route('/accounts') @app.route('/camera/<esn>/live_preview')
@login_required def camera_live_preivew(esn=None):
def accounts(): if 'een' in session:
# 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'] een = session['een']
else:
een = EagleEyev3()
camera = een.get_camera_by_id(esn) camera = een.get_camera_by_id(esn)
res = camera.get_live_preview() res = camera.get_live_preview()
@ -275,14 +125,16 @@ def camera__preivew_image(esn=None):
return send_file('static/placeholder.png') return send_file('static/placeholder.png')
@app.route('/camera/<esn>/preview') @app.route('/camera/<esn>')
@login_required def camera_detail(esn=None):
def camera_live_preivew(esn=None): if 'een' in session:
# get een instance from session, @login_required already checked for it
een = session['een'] een = session['een']
else:
een = EagleEyev3()
camera = een.get_camera_by_id(esn) 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 = { values = {
"current_user": een.current_user, "current_user": een.current_user,
@ -290,84 +142,7 @@ def camera_live_preivew(esn=None):
"events": camera.events['status'] "events": camera.events['status']
} }
return render_template('camera_preview.html', template_values=values) return render_template('camera_detail_partial.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>"
@ -376,4 +151,4 @@ def camera_status_plot(esn=None):
if __name__ == '__main__': if __name__ == '__main__':
app.run(host=config.server_host, port=config.server_port) app.run(host=een.server_host, port=een.server_port)

View File

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

View File

@ -9,13 +9,7 @@ idna==3.4
itsdangerous==2.1.2 itsdangerous==2.1.2
Jinja2==3.1.2 Jinja2==3.1.2
MarkupSafe==2.1.3 MarkupSafe==2.1.3
pytz==2023.3
requests==2.29.0
urllib3==1.26.16 urllib3==1.26.16
Werkzeug==2.3.6 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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,106 +0,0 @@
/*
* 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);
}

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -1,27 +0,0 @@
<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,22 +26,6 @@
<!-- self-hosted fontawesome --> <!-- self-hosted fontawesome -->
<link href="/static/css/all.css" rel="stylesheet"> <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 %} {% block style %}
{% endblock %} {% endblock %}
@ -59,7 +43,7 @@
<ul class="navbar-nav mr-auto mt-2 mt-lg-0"> <ul class="navbar-nav mr-auto mt-2 mt-lg-0">
<li class="nav-item active"> <li class="nav-item active">
<a href="/" class="navbar-brand d-flex align-items-center"> <a href="/" class="navbar-brand d-flex align-items-center">
<small>status</small>.mcotton.space <strong><small>status</small>.mcotton.space</strong>
</a> </a>
</li> </li>

View File

@ -1,3 +1,24 @@
<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;"> <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>

View File

@ -1,46 +0,0 @@
<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

@ -1,3 +0,0 @@
<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,61 +1,28 @@
<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> <div class="col-md-3" id="camera_list_items">
<h3>Online</h3>
<ul>
{% for camera in template_values['cameras'] %} {% for camera in template_values['cameras'] %}
{% if camera.is_online() %} {% if camera.is_online() %}
<div class="row"> <li hx-get="/camera/{{ camera.id }}" hx-trigger="click" hx-target="#camera_detail"> {{ camera.name }}</li>
<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 %} {% endif %}
{% endfor %} {% endfor %}
</ul>
<h5>Offline <small>[{{ template_values['camera_count_offline'] }} of {{ template_values['camera_count'] }}]</small></h5> <h3>Offline</h3>
<ul>
{% for camera in template_values['cameras'] %} {% for camera in template_values['cameras'] %}
{% if camera.is_offline() %} {% if not camera.is_online() %}
<div class="row"> <li hx-get="/camera/{{ camera.id }}" hx-trigger="click" hx-target="#camera_detail"> {{ camera.name }}</li>
<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 %} {% endif %}
{% endfor %} {% 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>

View File

@ -1,61 +0,0 @@
<!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,110 +12,6 @@
overflow: auto; 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> </style>
{% endblock %} {% endblock %}
@ -126,33 +22,11 @@
{% block main %} {% 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="container text-center">
<div class="row"> <div class="row" id="camera_list">
<div class="col-md-5" id="camera_list" style="max-height:400px; overflow:auto;">
{% include 'cameras_partial.html' %} {% include 'cameras_partial.html' %}
</div> </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> </div>

View File

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