Compare commits

...

16 Commits

Author SHA1 Message Date
tanner 8044207216 Say alert is from Doormind 2025-08-01 21:05:28 +00:00
tanner 7e752562bc Remove logging 2025-08-01 21:04:42 +00:00
tanner 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
tanner 5b0573abc7 feat: send open door alerts to controller 2025-08-01 21:04:42 +00:00
tanner 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
tanner 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
tanner f0f16a6841 fix: remove array from door state response 2025-08-01 21:02:35 +00:00
tanner 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
tanner 2556858912 refactor: move get_derived_state function to top of file 2025-08-01 21:02:35 +00:00
tanner 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
tanner eb5f9ba00a Ignore the model and add example key 2025-07-31 20:24:48 -06:00
tanner 85e02640d3 Only save previous state if it's known 2025-07-31 19:52:56 -06:00
tanner 16709de883 Add example secrets file 2025-07-31 19:39:56 -06:00
tanner 59b02e18e9 Freeze requirements 2025-07-31 19:39:10 -06:00
tanner 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
tanner 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
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
View File
@@ -0,0 +1,2 @@
BLUEIRIS_KEY = ''
+37
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
+111 -21
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)
if confidence < UNSURE_CONFIDENCE_THRESHOLD:
# Sanitize timestamp for use in filename
timestamp = datetime.now().isoformat().replace(':', '-')
filename = f"{timestamp}.jpg"
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)
@@ -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()