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/
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

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 aiohttp
from aiohttp import web
import logging
import os
import io
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
POLL_INTERVAL_SECONDS = 10
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):
@@ -54,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:
@@ -71,13 +109,68 @@ async def monitor_garage_door(app):
if result:
prediction, confidence = result
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:
logging.error(f"Failed to fetch image. Status: {response.status}, Reason: {response.reason}")
except asyncio.TimeoutError:
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:
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:
logging.info("Monitoring task cancelled.")
break
@@ -87,11 +180,54 @@ 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."""
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):
"""Actions to perform on application startup."""
# Set up device
@@ -111,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()
@@ -126,6 +265,7 @@ async def on_cleanup(app):
def main():
app = web.Application()
app.router.add_get('/', handle_root)
app.router.add_get('/state', handle_state)
app.on_startup.append(on_startup)
app.on_cleanup.append(on_cleanup)
web.run_app(app, port=8081)