Compare commits

...

46 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
20 changed files with 6246 additions and 528 deletions

2
.gitignore vendored
View File

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

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,395 +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": [
{
"name": "stderr",
"output_type": "stream",
"text": [
"INFO:root:Using EagleEyev3 version 0.0.4\n"
]
}
],
"source": [
"from EagleEyev3 import *\n",
"from settings import config\n",
"\n",
"logging.info(f\"Using EagleEyev3 version {EagleEyev3.__version__}\")"
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "486a2537",
"metadata": {},
"outputs": [],
"source": [
"een = EagleEyev3(config)"
]
},
{
"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": 8,
"id": "8d40a4e4",
"metadata": {},
"outputs": [],
"source": [
"een.access_token = \"eyJraWQiOiI2ODYxYjBjYS0wZjI2LTExZWQtODYxZC0wMjQyYWMxMjAwMDIiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJjYWZlZGVmMiIsImF1ZCI6InZtcy5hcGkiLCJpc3MiOiJ2bXMuYXV0aC52MSIsInZtc19hY2NvdW50IjoiMDAwMjgyMDEiLCJleHAiOjE2OTA0MzM2MDMsImlhdCI6MTY4OTgyODgwMywianRpIjoiMTIzYmYwZmVmMDk0MDFlZmM0MWYxODhjODVhMGY3MTAiLCJjbGllbnRfaWQiOiIzMmRhYzMzMDY0OWI0ODI3OWY5Y2FjYzJiNmY1N2FlNSIsInZtc19jbHVzdGVyIjoiYzAxMiJ9.O3vQ4B8jRmxKIfTUzMWKVhTCecsyG3J5ARR1yrYDjero3cor1cMyVTdaUwn_pe_vWAVL4je3H2NY0nNlMHIBDbVzfIa8LqPzWfawylQN4-258gixeyjWw1vRUf99hrQXEBa4cTUvi_p7bzyARt8EPOnDczfyAx31r5z3Rt6l21kAe0uCuzCZ4W7VhbWLD9tPADBRnKb6fiS_ZA6U9DpMMWXwT1hnEBklbKOUlsi30r9AQNFGVcL_159OTde0K031LHnyRPv3LnRyXzjZpkaY9zsR--1hq_nCgNipdk42R0rePCF-hw-J0oGpj-vAsHqKadkHgy6cKsqMoE1am1kuKQ\""
]
},
{
"cell_type": "code",
"execution_count": 11,
"id": "543dae39",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"{'success': True,\n",
" 'response_http_status': 200,\n",
" 'data': {'httpsBaseUrl': {'hostname': 'api.c012.eagleeyenetworks.com',\n",
" 'port': 443}}}"
]
},
"execution_count": 11,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"een.get_base_url(cascade=True)"
]
},
{
"cell_type": "code",
"execution_count": 12,
"id": "e14e2be5-a5f9-4b8c-ae60-76c61cb61b8b",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"'Mark Cotton - mcotton@mcottondesign.com'"
]
},
"execution_count": 12,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"f\"{een.current_user['firstName']} {een.current_user['lastName']} - {een.current_user['email']}\""
]
},
{
"cell_type": "code",
"execution_count": 13,
"id": "4ef47ae2-a010-4b7e-87f6-3dbf0a047e16",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"'eyJraWQiOiI2ODYxYjBjYS0wZjI2LTExZWQtODYxZC0wMjQyYWMxMjAwMDIiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJjYWZlZGVmMiIsImF1ZCI6InZtcy5hcGkiLCJpc3MiOiJ2bXMuYXV0aC52MSIsInZtc19hY2NvdW50IjoiMDAwMjgyMDEiLCJleHAiOjE2OTA0MzM2MDMsImlhdCI6MTY4OTgyODgwMywianRpIjoiMTIzYmYwZmVmMDk0MDFlZmM0MWYxODhjODVhMGY3MTAiLCJjbGllbnRfaWQiOiIzMmRhYzMzMDY0OWI0ODI3OWY5Y2FjYzJiNmY1N2FlNSIsInZtc19jbHVzdGVyIjoiYzAxMiJ9.O3vQ4B8jRmxKIfTUzMWKVhTCecsyG3J5ARR1yrYDjero3cor1cMyVTdaUwn_pe_vWAVL4je3H2NY0nNlMHIBDbVzfIa8LqPzWfawylQN4-258gixeyjWw1vRUf99hrQXEBa4cTUvi_p7bzyARt8EPOnDczfyAx31r5z3Rt6l21kAe0uCuzCZ4W7VhbWLD9tPADBRnKb6fiS_ZA6U9DpMMWXwT1hnEBklbKOUlsi30r9AQNFGVcL_159OTde0K031LHnyRPv3LnRyXzjZpkaY9zsR--1hq_nCgNipdk42R0rePCF-hw-J0oGpj-vAsHqKadkHgy6cKsqMoE1am1kuKQ'"
]
},
"execution_count": 13,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"een.access_token"
]
},
{
"cell_type": "markdown",
"id": "a22ff6c2",
"metadata": {},
"source": [
"## Get Cameras"
]
},
{
"cell_type": "code",
"execution_count": 14,
"id": "bb457850",
"metadata": {},
"outputs": [],
"source": [
"ret = een.get_list_of_cameras()"
]
},
{
"cell_type": "code",
"execution_count": 15,
"id": "c43f1db1",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"[✅ [1001423e] - ATM & Wine,\n",
" ✅ [10090759] - Benny Camera,\n",
" ✅ [100d8666] - Cash Register,\n",
" ✅ [1002f1d0] - FR test,\n",
" ✅ [10012735] - Fuel Dock,\n",
" ✅ [100b7b3b] - Max Camera,\n",
" ✅ [10009a85] - R2D2,\n",
" ✅ [1002584c] - Safe]"
]
},
"execution_count": 15,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"[i for i in een.cameras if i.is_online()]"
]
},
{
"cell_type": "code",
"execution_count": 16,
"id": "f4c6fe67",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"[]"
]
},
"execution_count": 16,
"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": 17,
"id": "74e78ee1-33b8-4a88-9d23-cd6281603a5b",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"2023-07-20T00:32:07.243-05:00 2023-07-19T18:32:07.243-05:00\n"
]
}
],
"source": [
"print(een.time_now(), een.time_before())"
]
},
{
"cell_type": "code",
"execution_count": 18,
"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": 19,
"id": "13809cc7-9ec2-4e15-9495-e64feaecca6d",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"5"
]
},
"execution_count": 19,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"len(een.cameras[2].events['status'])"
]
},
{
"cell_type": "code",
"execution_count": 20,
"id": "67d4f79b-2b43-4bdb-9068-28ac9d8d921c",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"0"
]
},
"execution_count": 20,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"len(een.cameras[0].events['motion'])"
]
},
{
"cell_type": "code",
"execution_count": 25,
"id": "c7513a23",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"[1008d090] Benny Camera [10090759]\n",
"[1008d090] FR test [1002f1d0]\n",
"[1008d090] Max Camera [100b7b3b]\n",
"[1008d090] R2D2 [10009a85]\n"
]
}
],
"source": [
"for cam in [i for i in een.cameras if i.bridge_id]:\n",
" print(f\"[{cam.bridge_id}] {cam.name} [{cam.id}]\")"
]
},
{
"cell_type": "code",
"execution_count": 22,
"id": "8be8d503-b46d-4ba7-884e-2c21c3987129",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"FR test - 2023-07-20T01:22:47.515+00:00 - bridgeOffline \n",
"FR test - 2023-07-19T23:32:08.348+00:00 - online \n",
"R2D2 - 2023-07-19T19:38:42.286+00:00 - bridgeOffline \n",
"R2D2 - 2023-07-19T17:32:10.875+00:00 - online \n"
]
}
],
"source": [
"for cam in [i for i in een.cameras if i.bridge_id]:\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

@ -22,6 +22,10 @@ config = {
"server_host": "127.0.0.1",
"server_port": "3333",
"server_path": "login_callback",
# preferences
"days_of_history": 1,
"log_level": "INFO"
}
```

273
app.py
View File

@ -15,60 +15,152 @@ 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 *
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(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 {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)
@ -76,28 +168,34 @@ 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:
# 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')
@ -105,28 +203,68 @@ def logout():
@app.route('/cameras')
@login_required
def cameras():
if 'een' in session:
een = session['een']
else:
een = EagleEyev3(config)
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(config)
camera = een.get_camera_by_id(esn)
res = camera.get_live_preview()
@ -137,19 +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(config)
camera = een.get_camera_by_id(esn)
now = een.time_now()
for i in tqdm(range(0,4)):
camera.get_list_of_events(end_timestamp=een.time_before(ts=now, hours=6*i), \
start_timestamp=een.time_before(ts=now, hours=6*(i+1)))
values = {
"current_user": een.current_user,
@ -157,27 +290,59 @@ 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):
if 'een' in session:
# get een instance from session, @login_required already checked for it
een = session['een']
else:
een = EagleEyev3(config)
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'], value=[1,0,0,0,0,0,0])
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)
data = imp.resample('S').bfill()
data['status'] = data['status'].astype('int64')
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)
@ -197,14 +362,18 @@ def camera_status_plot(esn=None):
# Save it to a temporary buffer.
buf = BytesIO()
fig.savefig(buf, format="png")
fig.savefig(buf, format="png", transparent=True)
# Embed the result in the html output.
data = base64.b64encode(buf.getbuffer()).decode("ascii")
return f"<div class='col-md-12'><img src='data:image/png;base64,{data}' width=70%/></div>"
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>"
if __name__ == '__main__':
app.run(host=een.server_host, port=een.server_port)
app.run(host=config.server_host, port=config.server_port)

View File

@ -14,7 +14,7 @@ Werkzeug==2.3.6
gunicorn==20.1.0
cachelib==0.10.2
Flask-Session==0.5.0
EagleEyev3>=0.0.4
EagleEyev3>=0.0.18
tqdm
pandas
numpy

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,30 +1,3 @@
<div class="row">
<div class="col-md-8">
<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-4" 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['endTimestamp'] }}</small>
<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 }}/status_plot" hx-trigger="click" hx-target="#camera_status_plot">
load plot
</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,41 +1,61 @@
<div class="col-md-3" id="camera_list_items">
<h3>Online</h3>
<ul>
{% for camera in template_values['cameras'] %}
{% if camera.is_online() and camera.bridge_id %}
<li hx-get="/camera/{{ camera.id }}" hx-trigger="click" hx-target="#camera_detail"> {{ camera.name }}</li>
{% endif %}
{% endfor %}
<hr>
{% for camera in template_values['cameras'] %}
{% if camera.is_online() and camera.bridge_id == None %}
<li hx-get="/camera/{{ camera.id }}" hx-trigger="click" hx-target="#camera_detail"> {{ camera.name }}</li>
{% endif %}
{% endfor %}
</ul>
<h3>Offline</h3>
<ul>
{% for camera in template_values['cameras'] %}
{% if camera.is_offline() and camera.bridge_id %}
<li hx-get="/camera/{{ camera.id }}" hx-trigger="click" hx-target="#camera_detail"> {{ camera.name }}</li>
{% endif %}
{% endfor %}
<hr>
{% for camera in template_values['cameras'] %}
{% if camera.is_offline() and camera.bridge_id == None %}
<li hx-get="/camera/{{ camera.id }}" hx-trigger="click" hx-target="#camera_detail"> {{ camera.name }}</li>
{% endif %}
{% endfor %}
</ul>
<div class="col-md-6 offset-3">
<button hx-get="/cameras" hx-trigger="click" hx-target="#camera_list">refresh</button>
<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() %}
<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>
<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 %}
<div class="col-md-9" id="camera_detail"></div>
<h5>Offline <small>[{{ template_values['camera_count_offline'] }} of {{ template_values['camera_count'] }}]</small></h5>
{% for camera in template_values['cameras'] %}
{% 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 %}
<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,13 +126,32 @@
{% 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="row" id="camera_status_plot">
<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>