Compare commits

...

16 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
4 changed files with 152 additions and 21 deletions

2
.gitignore vendored
View File

@@ -143,6 +143,7 @@ sdkconfig.old
data/
secrets.py
mysecrets.py
secrets.h
*.bin
output.*
@@ -151,3 +152,4 @@ out.*
*.txt
*.json
.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

132
server.py
View File

@@ -1,26 +1,28 @@
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 aiohttp
from aiohttp import web
import logging
import os
import io
from datetime import datetime
from datetime import datetime, timedelta
import torch
import torch.nn.functional as F
from torchvision import transforms
from PIL import Image
import mysecrets
from model import (CropLowerRightTriangle, GarageDoorCNN, TRIANGLE_CROP_WIDTH,
TRIANGLE_CROP_HEIGHT, RESIZE_DIM)
# --- Configuration ---
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"
MODEL_PATH = 'garage_door_cnn.pth'
CLASS_NAMES = ['closed', 'open'] # From training, sorted alphabetically
@@ -29,6 +31,37 @@ 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 ---
def get_prediction(model, image_bytes, device):
@@ -58,11 +91,12 @@ def get_prediction(model, image_bytes, device):
# --- Background Task ---
async def monitor_garage_door(app):
"""Periodically fetches an image and logs the garage door status."""
global LAST_OPEN_SAVE_TIME
logging.info("Starting garage door monitoring task.")
session = app['client_session']
model = app['model']
device = app['device']
headers = {'Authorization': 'Basic ' + BLUEIRIS_KEY}
headers = {'Authorization': 'Basic ' + mysecrets.BLUEIRIS_KEY}
while True:
try:
@@ -86,11 +120,10 @@ async def monitor_garage_door(app):
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:
# Sanitize timestamp for use in filename
timestamp = datetime.now().isoformat().replace(':', '-')
filename = f"{timestamp}.jpg"
# Construct path and save file
unsure_dir = os.path.join('data', 'unsure', prediction)
os.makedirs(unsure_dir, exist_ok=True)
@@ -100,6 +133,29 @@ async def monitor_garage_door(app):
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:
logging.error(f"Failed to fetch image. Status: {response.status}, Reason: {response.reason}")
@@ -124,6 +180,44 @@ async def monitor_garage_door(app):
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 ---
async def handle_root(request):
"""Handler for the root GET request."""
@@ -131,15 +225,8 @@ async def handle_root(request):
async def handle_state(request):
"""Handler for the /state GET request."""
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 web.Response(text=state)
state = get_derived_state()
return web.json_response({'door': state})
async def on_startup(app):
"""Actions to perform on application startup."""
@@ -160,13 +247,16 @@ async def on_startup(app):
# Start background task
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):
"""Actions to perform on application cleanup."""
logging.info("Cleaning up...")
app['monitor_task'].cancel()
app['state_monitor_task'].cancel()
try:
await app['monitor_task']
await app['state_monitor_task']
except asyncio.CancelledError:
pass
await app['client_session'].close()