Compare commits

...

21 Commits

Author SHA1 Message Date
8044207216 Say alert is from Doormind 2025-08-01 21:05:28 +00:00
7e752562bc Remove logging 2025-08-01 21:04:42 +00:00
f1d246aa31 refactor: make controller_message async using aiohttp
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-08-01 21:04:42 +00:00
5b0573abc7 feat: send open door alerts to controller 2025-08-01 21:04:42 +00:00
13ef45f72a fix: only reset alerts when door state is 'closed'
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-08-01 21:04:42 +00:00
bb1da6d836 feat: add timed alerts for open garage door
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-08-01 21:04:37 +00:00
f0f16a6841 fix: remove array from door state response 2025-08-01 21:02:35 +00:00
7a26f91cf1 feat: Return door state as JSON
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-08-01 21:02:35 +00:00
2556858912 refactor: move get_derived_state function to top of file 2025-08-01 21:02:35 +00:00
20c433af2d feat: save high-confidence images to sorted directories
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-08-01 21:02:35 +00:00
eb5f9ba00a Ignore the model and add example key 2025-07-31 20:24:48 -06:00
85e02640d3 Only save previous state if it's known 2025-07-31 19:52:56 -06:00
16709de883 Add example secrets file 2025-07-31 19:39:56 -06:00
59b02e18e9 Freeze requirements 2025-07-31 19:39:10 -06:00
de4c99bc1d feat: add background task to log state transitions
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-31 19:37:21 -06:00
914e8f9ce8 refactor: Move secrets to module and improve logging config 2025-07-31 19:37:08 -06:00
c23d99726d Spacing 2025-07-31 19:16:08 -06:00
a83f0d0937 fix: append 'unknown' to history on timeout or client error
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-31 19:14:38 -06:00
38ab26a659 feat: add GET /state handler to return determined state
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-31 19:11:06 -06:00
d923a4ac61 feat: track history of last 3 predictions
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-31 19:08:54 -06:00
dd53b40909 feat: log and save low-confidence predictions
Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
2025-07-31 19:02:02 -06:00
4 changed files with 188 additions and 7 deletions

2
.gitignore vendored
View File

@@ -143,6 +143,7 @@ sdkconfig.old
data/ data/
secrets.py secrets.py
mysecrets.py
secrets.h secrets.h
*.bin *.bin
output.* output.*
@@ -151,3 +152,4 @@ out.*
*.txt *.txt
*.json *.json
.aider* .aider*
*.pth

2
mysecrets.py.example Normal file
View File

@@ -0,0 +1,2 @@
BLUEIRIS_KEY = ''

37
requirements.txt Normal file
View File

@@ -0,0 +1,37 @@
aiohappyeyeballs==2.6.1
aiohttp==3.12.15
aiosignal==1.4.0
attrs==25.3.0
filelock==3.18.0
frozenlist==1.7.0
fsspec==2025.7.0
idna==3.10
jinja2==3.1.6
markupsafe==3.0.2
mpmath==1.3.0
multidict==6.6.3
networkx==3.5
numpy==2.3.2
nvidia-cublas-cu12==12.6.4.1
nvidia-cuda-cupti-cu12==12.6.80
nvidia-cuda-nvrtc-cu12==12.6.77
nvidia-cuda-runtime-cu12==12.6.77
nvidia-cudnn-cu12==9.5.1.17
nvidia-cufft-cu12==11.3.0.4
nvidia-cufile-cu12==1.11.1.6
nvidia-curand-cu12==10.3.7.77
nvidia-cusolver-cu12==11.7.1.2
nvidia-cusparse-cu12==12.5.4.2
nvidia-cusparselt-cu12==0.6.3
nvidia-nccl-cu12==2.26.2
nvidia-nvjitlink-cu12==12.6.85
nvidia-nvtx-cu12==12.6.77
pillow==11.3.0
propcache==0.3.2
setuptools==80.9.0
sympy==1.14.0
torch==2.7.1
torchvision==0.22.1
triton==3.3.1
typing-extensions==4.14.1
yarl==1.20.1

154
server.py
View File

@@ -1,30 +1,67 @@
import os, logging
DEBUG = os.environ.get('DEBUG')
logging.basicConfig(
format='[%(asctime)s] %(levelname)s %(module)s/%(funcName)s - %(message)s',
level=logging.DEBUG if DEBUG else logging.INFO)
logging.getLogger('aiohttp').setLevel(logging.DEBUG if DEBUG else logging.WARNING)
import asyncio import asyncio
import aiohttp import aiohttp
from aiohttp import web from aiohttp import web
import logging
import os
import io import io
from datetime import datetime, timedelta
import torch import torch
import torch.nn.functional as F import torch.nn.functional as F
from torchvision import transforms from torchvision import transforms
from PIL import Image from PIL import Image
import mysecrets
from model import (CropLowerRightTriangle, GarageDoorCNN, TRIANGLE_CROP_WIDTH, from model import (CropLowerRightTriangle, GarageDoorCNN, TRIANGLE_CROP_WIDTH,
TRIANGLE_CROP_HEIGHT, RESIZE_DIM) TRIANGLE_CROP_HEIGHT, RESIZE_DIM)
# --- Configuration --- # --- Configuration ---
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s') logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s')
BLUEIRIS_KEY = os.getenv('BLUEIRIS_KEY')
if not BLUEIRIS_KEY:
raise ValueError("BLUEIRIS_KEY environment variable not set.")
CAMERA_URL = "http://cameras.dns.t0.vc/image/SE-S?&w=9999&decode=1" CAMERA_URL = "http://cameras.dns.t0.vc/image/SE-S?&w=9999&decode=1"
MODEL_PATH = 'garage_door_cnn.pth' MODEL_PATH = 'garage_door_cnn.pth'
CLASS_NAMES = ['closed', 'open'] # From training, sorted alphabetically CLASS_NAMES = ['closed', 'open'] # From training, sorted alphabetically
POLL_INTERVAL_SECONDS = 10 POLL_INTERVAL_SECONDS = 10
REQUEST_TIMEOUT_SECONDS = 5 REQUEST_TIMEOUT_SECONDS = 5
UNSURE_CONFIDENCE_THRESHOLD = 0.97
PREDICTION_HISTORY = []
PREDICTION_HISTORY_MAX_LENGTH = 3
PREVIOUS_STATE = "unknown"
LAST_OPEN_SAVE_TIME = None
DOOR_OPEN_START_TIME = None
OPEN_ALERT_THRESHOLDS_MINUTES = [5, 15, 30, 60, 120]
OPEN_ALERTS_SENT_FOR_CURRENT_OPENING = []
async def controller_message(app, message):
payload = {mysecrets.CONTROLLER_KEY: message}
session = app['client_session']
try:
async with session.post(mysecrets.CONTROLLER_URL, data=payload, timeout=10) as response:
if response.status == 200:
return True
else:
logging.error(f'Unable to communicate with controller! Message: {message}, Status: {response.status}')
return False
except Exception:
logging.exception('Unable to communicate with controller! Message: ' + message)
return False
def get_derived_state():
"""Derives the state from the prediction history."""
state = "unknown"
if len(PREDICTION_HISTORY) == PREDICTION_HISTORY_MAX_LENGTH:
if all(s == "open" for s in PREDICTION_HISTORY):
state = "open"
elif all(s == "closed" for s in PREDICTION_HISTORY):
state = "closed"
return state
# --- Model Inference --- # --- Model Inference ---
def get_prediction(model, image_bytes, device): def get_prediction(model, image_bytes, device):
@@ -54,11 +91,12 @@ def get_prediction(model, image_bytes, device):
# --- Background Task --- # --- Background Task ---
async def monitor_garage_door(app): async def monitor_garage_door(app):
"""Periodically fetches an image and logs the garage door status.""" """Periodically fetches an image and logs the garage door status."""
global LAST_OPEN_SAVE_TIME
logging.info("Starting garage door monitoring task.") logging.info("Starting garage door monitoring task.")
session = app['client_session'] session = app['client_session']
model = app['model'] model = app['model']
device = app['device'] device = app['device']
headers = {'Authorization': 'Basic ' + BLUEIRIS_KEY} headers = {'Authorization': 'Basic ' + mysecrets.BLUEIRIS_KEY}
while True: while True:
try: try:
@@ -71,13 +109,68 @@ async def monitor_garage_door(app):
if result: if result:
prediction, confidence = result prediction, confidence = result
logging.debug(f"Garage door status: {prediction} (confidence: {confidence:.4f})") logging.debug(f"Garage door status: {prediction} (confidence: {confidence:.4f})")
# Update prediction history
if confidence >= UNSURE_CONFIDENCE_THRESHOLD:
PREDICTION_HISTORY.append(prediction)
else:
PREDICTION_HISTORY.append('unknown')
# Trim history if it's too long
if len(PREDICTION_HISTORY) > PREDICTION_HISTORY_MAX_LENGTH:
PREDICTION_HISTORY.pop(0)
timestamp = datetime.now().isoformat().replace(':', '-')
filename = f"{timestamp}.jpg"
if confidence < UNSURE_CONFIDENCE_THRESHOLD:
# Construct path and save file
unsure_dir = os.path.join('data', 'unsure', prediction)
os.makedirs(unsure_dir, exist_ok=True)
filepath = os.path.join(unsure_dir, filename)
with open(filepath, 'wb') as f:
f.write(image_bytes)
logging.info(f"Low confidence prediction: {prediction} ({confidence:.4f}). Saved for review: {filepath}")
else:
# High confidence, save to sorted
if get_derived_state() == 'open':
if LAST_OPEN_SAVE_TIME is None or (datetime.now() - LAST_OPEN_SAVE_TIME) > timedelta(minutes=5):
sorted_dir = os.path.join('data', 'sorted', 'open')
os.makedirs(sorted_dir, exist_ok=True)
filepath = os.path.join(sorted_dir, filename)
with open(filepath, 'wb') as f:
f.write(image_bytes)
LAST_OPEN_SAVE_TIME = datetime.now()
logging.info(f"Saved high-confidence 'open' image: {filepath}")
elif get_derived_state() == 'closed':
open_dir = os.path.join('data', 'sorted', 'open')
closed_dir = os.path.join('data', 'sorted', 'closed')
os.makedirs(open_dir, exist_ok=True)
os.makedirs(closed_dir, exist_ok=True)
num_open = len(os.listdir(open_dir))
num_closed = len(os.listdir(closed_dir))
if num_closed < num_open:
filepath = os.path.join(closed_dir, filename)
with open(filepath, 'wb') as f:
f.write(image_bytes)
logging.info(f"Saved high-confidence 'closed' image: {filepath}")
else: else:
logging.error(f"Failed to fetch image. Status: {response.status}, Reason: {response.reason}") logging.error(f"Failed to fetch image. Status: {response.status}, Reason: {response.reason}")
except asyncio.TimeoutError: except asyncio.TimeoutError:
logging.warning("Request to camera timed out.") logging.warning("Request to camera timed out.")
PREDICTION_HISTORY.append('unknown')
if len(PREDICTION_HISTORY) > PREDICTION_HISTORY_MAX_LENGTH:
PREDICTION_HISTORY.pop(0)
except aiohttp.ClientError as e: except aiohttp.ClientError as e:
logging.error(f"Client error during image fetch: {e}") logging.error(f"Client error during image fetch: {e}")
PREDICTION_HISTORY.append('unknown')
if len(PREDICTION_HISTORY) > PREDICTION_HISTORY_MAX_LENGTH:
PREDICTION_HISTORY.pop(0)
except asyncio.CancelledError: except asyncio.CancelledError:
logging.info("Monitoring task cancelled.") logging.info("Monitoring task cancelled.")
break break
@@ -87,11 +180,54 @@ async def monitor_garage_door(app):
await asyncio.sleep(5) await asyncio.sleep(5)
async def monitor_state_transitions(app):
"""Periodically checks for state transitions and logs them."""
global PREVIOUS_STATE, DOOR_OPEN_START_TIME, OPEN_ALERTS_SENT_FOR_CURRENT_OPENING
logging.info("Starting state transition monitoring task.")
while True:
try:
await asyncio.sleep(5)
current_state = get_derived_state()
if current_state != "unknown" and current_state != PREVIOUS_STATE:
logging.info(f"State transitioned from '{PREVIOUS_STATE}' to '{current_state}'.")
PREVIOUS_STATE = current_state
if current_state == 'open':
if DOOR_OPEN_START_TIME is None:
DOOR_OPEN_START_TIME = datetime.now()
OPEN_ALERTS_SENT_FOR_CURRENT_OPENING = []
open_duration = datetime.now() - DOOR_OPEN_START_TIME
open_duration_minutes = open_duration.total_seconds() / 60
for threshold in OPEN_ALERT_THRESHOLDS_MINUTES:
if open_duration_minutes >= threshold and threshold not in OPEN_ALERTS_SENT_FOR_CURRENT_OPENING:
msg = f"Doormind: Garage door has been open for {threshold} minutes."
await controller_message(app, msg)
logging.info(msg)
OPEN_ALERTS_SENT_FOR_CURRENT_OPENING.append(threshold)
elif current_state == 'closed':
DOOR_OPEN_START_TIME = None
OPEN_ALERTS_SENT_FOR_CURRENT_OPENING = []
except asyncio.CancelledError:
logging.info("State transition monitoring task cancelled.")
break
except Exception as e:
logging.error(f"An unexpected error occurred in the state monitoring task: {e}", exc_info=True)
await asyncio.sleep(5)
# --- Web Server --- # --- Web Server ---
async def handle_root(request): async def handle_root(request):
"""Handler for the root GET request.""" """Handler for the root GET request."""
return web.Response(text="hello world") return web.Response(text="hello world")
async def handle_state(request):
"""Handler for the /state GET request."""
state = get_derived_state()
return web.json_response({'door': state})
async def on_startup(app): async def on_startup(app):
"""Actions to perform on application startup.""" """Actions to perform on application startup."""
# Set up device # Set up device
@@ -111,13 +247,16 @@ async def on_startup(app):
# Start background task # Start background task
app['monitor_task'] = asyncio.create_task(monitor_garage_door(app)) app['monitor_task'] = asyncio.create_task(monitor_garage_door(app))
app['state_monitor_task'] = asyncio.create_task(monitor_state_transitions(app))
async def on_cleanup(app): async def on_cleanup(app):
"""Actions to perform on application cleanup.""" """Actions to perform on application cleanup."""
logging.info("Cleaning up...") logging.info("Cleaning up...")
app['monitor_task'].cancel() app['monitor_task'].cancel()
app['state_monitor_task'].cancel()
try: try:
await app['monitor_task'] await app['monitor_task']
await app['state_monitor_task']
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
await app['client_session'].close() await app['client_session'].close()
@@ -126,6 +265,7 @@ async def on_cleanup(app):
def main(): def main():
app = web.Application() app = web.Application()
app.router.add_get('/', handle_root) app.router.add_get('/', handle_root)
app.router.add_get('/state', handle_state)
app.on_startup.append(on_startup) app.on_startup.append(on_startup)
app.on_cleanup.append(on_cleanup) app.on_cleanup.append(on_cleanup)
web.run_app(app, port=8081) web.run_app(app, port=8081)