Compare commits
10 Commits
0b04734818
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b4db2fd14a | |||
| 8a1779181c | |||
| b4a96ee9e0 | |||
| 5794ba54fa | |||
| 3aa2731617 | |||
| 13b6e88518 | |||
| 68749e219b | |||
| 41719c4557 | |||
| 320f0a1a2a | |||
| 24ec0605da |
61
README.md
61
README.md
@ -6,18 +6,18 @@ This repository provides an SMTP service for Frigate, enabling automated email n
|
|||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- [Docker](https://www.docker.com/get-started) installed on your system
|
* [Docker](https://www.docker.com/get-started) installed on your system
|
||||||
- [Docker Compose](https://docs.docker.com/compose/install/) installed
|
* [Docker Compose](https://docs.docker.com/compose/install/) installed
|
||||||
- Git installed to clone the repository
|
* Git installed to clone the repository
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
/docker
|
/docker
|
||||||
├── main.py # Main application script
|
|
||||||
├── generate_config.py # Script to generate configuration
|
|
||||||
├── Dockerfile # Docker image definition
|
├── Dockerfile # Docker image definition
|
||||||
├── docker-compose.yaml # Docker Compose configuration
|
├── docker-compose.yaml # Docker Compose configuration
|
||||||
|
├── generate_config.py # Script to generate configuration
|
||||||
|
├── main.py # Main application script
|
||||||
└── requirements.txt # Python dependencies
|
└── requirements.txt # Python dependencies
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -28,7 +28,7 @@ Follow these steps to set up and run the Frigate SMTP service:
|
|||||||
1. **Clone the Repository**
|
1. **Clone the Repository**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/The-Dark-Mode/frigate-smtp
|
git clone https://github.com/UndercoverComputing/frigate-smtp
|
||||||
cd frigate-smtp/docker/
|
cd frigate-smtp/docker/
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -37,6 +37,7 @@ Follow these steps to set up and run the Frigate SMTP service:
|
|||||||
Open the `docker-compose.yaml` file in a text editor and define the necessary environment variables. Ensure all required variables (e.g., SMTP server settings) are correctly set.
|
Open the `docker-compose.yaml` file in a text editor and define the necessary environment variables. Ensure all required variables (e.g., SMTP server settings) are correctly set.
|
||||||
|
|
||||||
Example snippet from `docker-compose.yaml`:
|
Example snippet from `docker-compose.yaml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
frigate-smtp:
|
frigate-smtp:
|
||||||
@ -57,12 +58,44 @@ Follow these steps to set up and run the Frigate SMTP service:
|
|||||||
MQTT_PORT: 1883 # MQTT broker port
|
MQTT_PORT: 1883 # MQTT broker port
|
||||||
MQTT_USERNAME: mqttuser # MQTT username for authentication
|
MQTT_USERNAME: mqttuser # MQTT username for authentication
|
||||||
MQTT_PASSWORD: mqttpass # MQTT password for authentication
|
MQTT_PASSWORD: mqttpass # MQTT password for authentication
|
||||||
ALLOWED_CAMERAS: camera1,camera2 # List of cameras to allow (comma-separated)
|
|
||||||
IGNORED_LABELS: label1,label2 # Labels to ignore - e.g. car, person, cat; if none, set to "..."
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Replace `your.smtp.server`, `587`, `your_username`, and `your_password` with your actual SMTP server details.
|
Replace `your.smtp.server`, `587`, `your_username`, and `your_password` with your actual SMTP server details.
|
||||||
|
|
||||||
|
**Per-camera rules**
|
||||||
|
|
||||||
|
Example `alert_rules.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"driveway": {
|
||||||
|
"labels": ["person", "car"],
|
||||||
|
"zones": ["Drive"]
|
||||||
|
},
|
||||||
|
"backyard": {
|
||||||
|
"labels": ["person", "dog"],
|
||||||
|
"zones": []
|
||||||
|
},
|
||||||
|
"garage": {
|
||||||
|
"labels": ["person"],
|
||||||
|
"zones": ["Front", "Side"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
* **labels**: list of labels to allow for that camera (case-insensitive).
|
||||||
|
* **zones**: list of zones to allow for that camera (case-insensitive). If left empty or omitted, events in all zones will trigger notifications for that camera.
|
||||||
|
* If the zones list is non-empty, events detected outside those zones for that camera will **not** generate an email alert.
|
||||||
|
|
||||||
|
Mount the file in Docker so the container can read it:
|
||||||
|
docker-compose.yaml:
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ./alert_rules.json:/app/alert_rules.json:ro
|
||||||
|
```
|
||||||
|
|
||||||
|
If `alert_rules.json` is present, the script will check the camera name, allowed labels, and allowed zones defined in that file. Cameras not listed in the file will be ignored. If `alert_rules.json` is not present, the script falls back to `ALLOWED_CAMERAS` and `IGNORED_LABELS`.
|
||||||
|
|
||||||
3. **Run the Application**
|
3. **Run the Application**
|
||||||
|
|
||||||
Build and start the Docker containers in detached mode:
|
Build and start the Docker containers in detached mode:
|
||||||
@ -71,8 +104,8 @@ Follow these steps to set up and run the Frigate SMTP service:
|
|||||||
docker compose up --build -d
|
docker compose up --build -d
|
||||||
```
|
```
|
||||||
|
|
||||||
- The `--build` flag ensures the Docker image is built before starting.
|
* The `--build` flag ensures the Docker image is built before starting.
|
||||||
- The `-d` flag runs the containers in the background.
|
* The `-d` flag runs the containers in the background.
|
||||||
|
|
||||||
4. **Verify the Service**
|
4. **Verify the Service**
|
||||||
|
|
||||||
@ -92,10 +125,10 @@ docker compose down
|
|||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
- Ensure all environment variables in `docker-compose.yaml` are correctly set.
|
* Ensure all environment variables in `docker-compose.yaml` are correctly set.
|
||||||
- Verify that your SMTP server is accessible and the credentials are valid.
|
* Verify that your SMTP server is accessible and the credentials are valid.
|
||||||
- Check Docker logs for errors: `docker compose logs frigate-smtp`.
|
* Check Docker logs for errors: `docker compose logs frigate-smtp`.
|
||||||
- Ensure Docker and Docker Compose are up to date.
|
* Ensure Docker and Docker Compose are up to date.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
14
alert_rules.json
Normal file
14
alert_rules.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"driveway": {
|
||||||
|
"labels": ["person", "car"],
|
||||||
|
"zones": ["Drive"]
|
||||||
|
},
|
||||||
|
"backyard": {
|
||||||
|
"labels": ["person", "dog"],
|
||||||
|
"zones": []
|
||||||
|
},
|
||||||
|
"garage": {
|
||||||
|
"labels": ["person"],
|
||||||
|
"zones": ["Front", "Side"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,6 +17,4 @@
|
|||||||
"username": "mqtt username",
|
"username": "mqtt username",
|
||||||
"password": "mqtt password"
|
"password": "mqtt password"
|
||||||
}
|
}
|
||||||
"allowed_cameras": ["gate"],
|
|
||||||
"ignored_labels": ["person"]
|
|
||||||
}
|
}
|
||||||
14
docker/alert_rules.json
Normal file
14
docker/alert_rules.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"driveway": {
|
||||||
|
"labels": ["person", "car"],
|
||||||
|
"zones": ["Drive"]
|
||||||
|
},
|
||||||
|
"backyard": {
|
||||||
|
"labels": ["person", "dog"],
|
||||||
|
"zones": []
|
||||||
|
},
|
||||||
|
"garage": {
|
||||||
|
"labels": ["person"],
|
||||||
|
"zones": ["Front", "Side"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,11 +11,11 @@ services:
|
|||||||
SMTP_PASSWORD: yourpassword # SMTP password for authentication
|
SMTP_PASSWORD: yourpassword # SMTP password for authentication
|
||||||
EMAIL_FROM: user@example.com # Sender email address
|
EMAIL_FROM: user@example.com # Sender email address
|
||||||
EMAIL_TO: you@example.com,friend@example.com # Recipient email addresses (comma-separated)
|
EMAIL_TO: you@example.com,friend@example.com # Recipient email addresses (comma-separated)
|
||||||
HOMEASSISTANT_URL: https://ha.domain.com # URL to Home Assistant instance (external)
|
HOMEASSISTANT_URL: https://ha.domain.com # URL to Home Assistant instance (external) (Without a slash at the end)
|
||||||
HOMEASSISTANT_IP: http://ha-ip:8123 # Home Assistant internal IP URL
|
HOMEASSISTANT_IP: http://ha-ip:8123 # Home Assistant internal IP URL (Without a slash at the end)
|
||||||
MQTT_BROKER_IP: mqtt-ip # MQTT broker IP address
|
MQTT_BROKER_IP: mqtt-ip # MQTT broker IP address
|
||||||
MQTT_PORT: 1883 # MQTT broker port
|
MQTT_PORT: 1883 # MQTT broker port
|
||||||
MQTT_USERNAME: mqttuser # MQTT username for authentication
|
MQTT_USERNAME: mqttuser # MQTT username for authentication
|
||||||
MQTT_PASSWORD: mqttpass # MQTT password for authentication
|
MQTT_PASSWORD: mqttpass # MQTT password for authentication
|
||||||
ALLOWED_CAMERAS: camera1,camera2 # List of cameras to allow (comma-separated)
|
volumes:
|
||||||
IGNORED_LABELS: label1,label2 # Labels to ignore - e.g. car, person, cat; if none, set to "..."
|
- ./alert_rules.json:/app/alert_rules.json:ro
|
||||||
|
|||||||
@ -17,10 +17,13 @@ config = {
|
|||||||
"port": int(os.getenv("MQTT_PORT", 1883)),
|
"port": int(os.getenv("MQTT_PORT", 1883)),
|
||||||
"username": os.getenv("MQTT_USERNAME", ""),
|
"username": os.getenv("MQTT_USERNAME", ""),
|
||||||
"password": os.getenv("MQTT_PASSWORD", "")
|
"password": os.getenv("MQTT_PASSWORD", "")
|
||||||
},
|
}
|
||||||
"allowed_cameras": os.getenv("ALLOWED_CAMERAS", "").split(",") if os.getenv("ALLOWED_CAMERAS") else [],
|
|
||||||
"ignored_labels": os.getenv("IGNORED_LABELS", "").split(",") if os.getenv("IGNORED_LABELS") else []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rules_path = os.getenv("ALERT_RULES_FILE", "alert_rules.json")
|
||||||
|
if os.path.exists(rules_path):
|
||||||
|
with open(rules_path, "r") as f:
|
||||||
|
config["alert_rules"] = json.load(f)
|
||||||
|
|
||||||
with open("config.json", "w") as f:
|
with open("config.json", "w") as f:
|
||||||
json.dump(config, f, indent=2)
|
json.dump(config, f, indent=2)
|
||||||
114
docker/main.py
114
docker/main.py
@ -10,7 +10,6 @@ import time
|
|||||||
import threading
|
import threading
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
# Logging setup
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format='%(asctime)s [%(levelname)s] %(message)s'
|
format='%(asctime)s [%(levelname)s] %(message)s'
|
||||||
@ -28,18 +27,73 @@ SMTP_PASSWORD = config["smtp"]["password"]
|
|||||||
EMAIL_FROM = config["smtp"]["from"]
|
EMAIL_FROM = config["smtp"]["from"]
|
||||||
EMAIL_TO = config["smtp"]["to"]
|
EMAIL_TO = config["smtp"]["to"]
|
||||||
HOMEASSISTANT_URL = config["homeassistant_url"]
|
HOMEASSISTANT_URL = config["homeassistant_url"]
|
||||||
HOMEASSISTANT_IP = config["homeassistant_ip"]
|
HOMEASSISTANT_IP = config.get("homeassistant_ip", HOMEASSISTANT_URL)
|
||||||
|
|
||||||
MQTT_BROKER_IP = config["mqtt"]["broker_ip"]
|
MQTT_BROKER_IP = config["mqtt"]["broker_ip"]
|
||||||
MQTT_PORT = config["mqtt"]["port"]
|
MQTT_PORT = config["mqtt"]["port"]
|
||||||
MQTT_USERNAME = config["mqtt"]["username"]
|
MQTT_USERNAME = config["mqtt"]["username"]
|
||||||
MQTT_PASSWORD = config["mqtt"]["password"]
|
MQTT_PASSWORD = config["mqtt"]["password"]
|
||||||
|
|
||||||
FILTERED_CAMERAS = config.get("allowed_cameras", [])
|
# Load alert rules
|
||||||
IGNORED_LABELS = config.get("ignored_labels", [])
|
try:
|
||||||
|
with open("alert_rules.json", "r") as f:
|
||||||
|
alert_rules_raw = json.load(f)
|
||||||
|
logger.info(f"Loaded alert_rules.json: {alert_rules_raw}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load alert_rules.json, no events will be processed: {e}")
|
||||||
|
alert_rules_raw = {}
|
||||||
|
|
||||||
|
alert_rules = {}
|
||||||
|
for cam, rules in alert_rules_raw.items():
|
||||||
|
alert_rules[cam.lower()] = {
|
||||||
|
"labels": [lbl.lower() for lbl in rules.get("labels", [])],
|
||||||
|
"ignore": [lbl.lower() for lbl in rules.get("ignore", [])],
|
||||||
|
"zones": [zone.lower() for zone in rules.get("zones", [])]
|
||||||
|
}
|
||||||
|
|
||||||
event_cache = {}
|
event_cache = {}
|
||||||
|
|
||||||
|
|
||||||
|
def rule_allows_event(camera, label, zones):
|
||||||
|
cam_key = camera.lower()
|
||||||
|
lbl = label.lower()
|
||||||
|
zones_check = [z.lower() for z in zones] if zones else []
|
||||||
|
|
||||||
|
if cam_key not in alert_rules:
|
||||||
|
return False
|
||||||
|
|
||||||
|
rule = alert_rules[cam_key]
|
||||||
|
|
||||||
|
if rule["labels"] and lbl not in rule["labels"]:
|
||||||
|
return False
|
||||||
|
if rule["ignore"] and lbl in rule["ignore"]:
|
||||||
|
return False
|
||||||
|
if rule["zones"]:
|
||||||
|
if not zones_check:
|
||||||
|
return False
|
||||||
|
allowed_zones = [z.lower() for z in rule["zones"]]
|
||||||
|
if not any(zone in allowed_zones for zone in zones_check):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_snapshot_with_retry(snapshot_url, retries=5, delay=1):
|
||||||
|
"""
|
||||||
|
Try to fetch a valid snapshot, retrying if it fails.
|
||||||
|
"""
|
||||||
|
for attempt in range(retries):
|
||||||
|
try:
|
||||||
|
response = requests.get(snapshot_url, timeout=5)
|
||||||
|
response.raise_for_status()
|
||||||
|
if 'image' in response.headers.get('Content-Type', ''):
|
||||||
|
return response.content
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Snapshot fetch failed (attempt {attempt+1}/{retries}): {e}")
|
||||||
|
time.sleep(delay)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def send_email(message, snapshot_urls, event_label, clip_url):
|
def send_email(message, snapshot_urls, event_label, clip_url):
|
||||||
subject = f"{event_label} detected!"
|
subject = f"{event_label} detected!"
|
||||||
msg = MIMEMultipart()
|
msg = MIMEMultipart()
|
||||||
@ -51,14 +105,9 @@ def send_email(message, snapshot_urls, event_label, clip_url):
|
|||||||
msg.attach(MIMEText(body))
|
msg.attach(MIMEText(body))
|
||||||
|
|
||||||
for snapshot_url in snapshot_urls:
|
for snapshot_url in snapshot_urls:
|
||||||
try:
|
image_bytes = fetch_snapshot_with_retry(snapshot_url)
|
||||||
response = requests.get(snapshot_url, timeout=5)
|
if image_bytes:
|
||||||
response.raise_for_status()
|
msg.attach(MIMEImage(image_bytes, name="snapshot.jpg"))
|
||||||
if 'image' in response.headers.get('Content-Type', ''):
|
|
||||||
image_data = BytesIO(response.content)
|
|
||||||
msg.attach(MIMEImage(image_data.read(), name="snapshot.jpg"))
|
|
||||||
except Exception:
|
|
||||||
pass # silent fail for snapshot issues
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with smtplib.SMTP(SMTP_SERVER, SMTP_PORT, timeout=10) as server:
|
with smtplib.SMTP(SMTP_SERVER, SMTP_PORT, timeout=10) as server:
|
||||||
@ -69,19 +118,26 @@ def send_email(message, snapshot_urls, event_label, clip_url):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to send email: {e}")
|
logger.error(f"Failed to send email: {e}")
|
||||||
|
|
||||||
def handle_event(event_id):
|
|
||||||
time.sleep(10) # Delay to collect full snapshots
|
|
||||||
|
|
||||||
|
def handle_event(event_id):
|
||||||
if event_id not in event_cache:
|
if event_id not in event_cache:
|
||||||
return
|
return
|
||||||
|
|
||||||
event_info = event_cache[event_id]
|
event_info = event_cache[event_id]
|
||||||
|
|
||||||
|
# Don’t send again if already emailed
|
||||||
|
if event_info.get("emailed"):
|
||||||
|
logger.debug(f"Skipping already emailed event: {event_id}")
|
||||||
|
return
|
||||||
|
|
||||||
clip_url = f"{HOMEASSISTANT_URL}/api/frigate/notifications/{event_id}/{event_info['camera']}/clip.mp4"
|
clip_url = f"{HOMEASSISTANT_URL}/api/frigate/notifications/{event_id}/{event_info['camera']}/clip.mp4"
|
||||||
message = f"A {event_info['event_label']} was detected on camera: {event_info['camera']}.\nEvent ID: {event_id}"
|
message = f"A {event_info['event_label']} was detected on camera: {event_info['camera']}.\nEvent ID: {event_id}"
|
||||||
|
|
||||||
send_email(message, event_info['snapshot_urls'], event_info['event_label'], clip_url)
|
send_email(message, event_info['snapshot_urls'], event_info['event_label'], clip_url)
|
||||||
|
|
||||||
|
event_cache[event_id]['emailed'] = True
|
||||||
logger.info(f"Processed and emailed event: {event_id}")
|
logger.info(f"Processed and emailed event: {event_id}")
|
||||||
event_cache.pop(event_id, None)
|
|
||||||
|
|
||||||
def on_message(client, userdata, message):
|
def on_message(client, userdata, message):
|
||||||
try:
|
try:
|
||||||
@ -93,36 +149,40 @@ def on_message(client, userdata, message):
|
|||||||
if not after:
|
if not after:
|
||||||
return
|
return
|
||||||
|
|
||||||
event_id = after.get("id")
|
|
||||||
event_label = after.get("label")
|
event_label = after.get("label")
|
||||||
|
event_id = after.get("id")
|
||||||
camera = after.get("camera")
|
camera = after.get("camera")
|
||||||
|
zones = after.get("current_zones") or after.get("entered_zones") or []
|
||||||
|
|
||||||
if not event_id or not event_label or not camera:
|
if not event_label or not event_id or not camera:
|
||||||
return
|
return
|
||||||
|
|
||||||
if FILTERED_CAMERAS and camera.lower() not in [c.lower() for c in FILTERED_CAMERAS]:
|
if not rule_allows_event(camera, event_label, zones):
|
||||||
return
|
logger.info(f"Event from camera '{camera}' with label '{event_label}' and zones '{zones}' blocked by alert rules.")
|
||||||
|
|
||||||
if event_label in IGNORED_LABELS:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
snapshot_url = f"{HOMEASSISTANT_IP}/api/frigate/notifications/{event_id}/snapshot.jpg"
|
snapshot_url = f"{HOMEASSISTANT_IP}/api/frigate/notifications/{event_id}/snapshot.jpg"
|
||||||
|
|
||||||
if event_id in event_cache:
|
if event_id not in event_cache:
|
||||||
event_cache[event_id]['snapshot_urls'].append(snapshot_url)
|
# First time seeing this event
|
||||||
else:
|
|
||||||
event_cache[event_id] = {
|
event_cache[event_id] = {
|
||||||
'event_label': event_label,
|
'event_label': event_label,
|
||||||
'camera': camera,
|
'camera': camera,
|
||||||
'snapshot_urls': [snapshot_url]
|
'snapshot_urls': [snapshot_url],
|
||||||
|
'emailed': False
|
||||||
}
|
}
|
||||||
|
# Send email immediately in a thread
|
||||||
threading.Thread(target=handle_event, args=(event_id,), daemon=True).start()
|
threading.Thread(target=handle_event, args=(event_id,), daemon=True).start()
|
||||||
|
else:
|
||||||
|
# Already seen this event → just collect snapshots
|
||||||
|
event_cache[event_id]['snapshot_urls'].append(snapshot_url)
|
||||||
|
|
||||||
logger.info(f"Received event: {event_label} from {camera} (Event ID: {event_id})")
|
logger.info(f"Received event: {event_label} from {camera} (Event ID: {event_id}, Zones: {zones})")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error processing MQTT message: {e}")
|
logger.error(f"Error processing MQTT message: {e}")
|
||||||
|
|
||||||
|
|
||||||
def on_connect(client, userdata, flags, rc, properties=None):
|
def on_connect(client, userdata, flags, rc, properties=None):
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
logger.error(f"MQTT connection failed with code {rc}")
|
logger.error(f"MQTT connection failed with code {rc}")
|
||||||
@ -135,6 +195,7 @@ def on_connect(client, userdata, flags, rc, properties=None):
|
|||||||
else:
|
else:
|
||||||
logger.debug("Reconnected to MQTT broker")
|
logger.debug("Reconnected to MQTT broker")
|
||||||
|
|
||||||
|
|
||||||
def connect_mqtt():
|
def connect_mqtt():
|
||||||
client = mqtt.Client(
|
client = mqtt.Client(
|
||||||
client_id="frigate_smtp",
|
client_id="frigate_smtp",
|
||||||
@ -156,5 +217,6 @@ def connect_mqtt():
|
|||||||
logger.error(f"MQTT connection failed: {e}. Retrying in 5 seconds...")
|
logger.error(f"MQTT connection failed: {e}. Retrying in 5 seconds...")
|
||||||
time.sleep(5)
|
time.sleep(5)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
connect_mqtt()
|
connect_mqtt()
|
||||||
|
|||||||
138
log.py
138
log.py
@ -1,4 +1,3 @@
|
|||||||
# Import necessary dependencies.
|
|
||||||
import paho.mqtt.client as mqtt
|
import paho.mqtt.client as mqtt
|
||||||
import smtplib
|
import smtplib
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
@ -11,7 +10,6 @@ from io import BytesIO
|
|||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
# Set up logging
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.DEBUG,
|
level=logging.DEBUG,
|
||||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||||
@ -21,7 +19,6 @@ logging.basicConfig(
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Load settings from config.json
|
|
||||||
with open('config.json', 'r') as f:
|
with open('config.json', 'r') as f:
|
||||||
config = json.load(f)
|
config = json.load(f)
|
||||||
|
|
||||||
@ -32,17 +29,64 @@ SMTP_PASSWORD = config["smtp"]["password"]
|
|||||||
EMAIL_FROM = config["smtp"]["from"]
|
EMAIL_FROM = config["smtp"]["from"]
|
||||||
EMAIL_TO = config["smtp"]["to"]
|
EMAIL_TO = config["smtp"]["to"]
|
||||||
HOMEASSISTANT_URL = config["homeassistant_url"]
|
HOMEASSISTANT_URL = config["homeassistant_url"]
|
||||||
|
HOMEASSISTANT_IP = config.get("homeassistant_ip", HOMEASSISTANT_URL)
|
||||||
|
|
||||||
# MQTT configuration
|
|
||||||
MQTT_BROKER_IP = config["mqtt"]["broker_ip"]
|
MQTT_BROKER_IP = config["mqtt"]["broker_ip"]
|
||||||
MQTT_PORT = config["mqtt"]["port"]
|
MQTT_PORT = config["mqtt"]["port"]
|
||||||
MQTT_USERNAME = config["mqtt"]["username"]
|
MQTT_USERNAME = config["mqtt"]["username"]
|
||||||
MQTT_PASSWORD = config["mqtt"]["password"]
|
MQTT_PASSWORD = config["mqtt"]["password"]
|
||||||
|
|
||||||
# Dictionary to track event IDs and email state
|
try:
|
||||||
|
with open("alert_rules.json", "r") as f:
|
||||||
|
alert_rules_raw = json.load(f)
|
||||||
|
logging.info(f"Loaded alert_rules.json: {alert_rules_raw}")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Failed to load alert_rules.json, no events will be processed: {e}")
|
||||||
|
alert_rules_raw = {}
|
||||||
|
|
||||||
|
alert_rules = {}
|
||||||
|
for cam, rules in alert_rules_raw.items():
|
||||||
|
alert_rules[cam.lower()] = {
|
||||||
|
"labels": [lbl.lower() for lbl in rules.get("labels", [])],
|
||||||
|
"ignore": [lbl.lower() for lbl in rules.get("ignore", [])],
|
||||||
|
"zones": [zone.lower() for zone in rules.get("zones", [])]
|
||||||
|
}
|
||||||
|
|
||||||
event_cache = {}
|
event_cache = {}
|
||||||
|
|
||||||
# Function to send email with attachment and clip link
|
def rule_allows_event(camera, label, zones):
|
||||||
|
cam_key = camera.lower()
|
||||||
|
lbl = label.lower()
|
||||||
|
zones_check = [z.lower() for z in zones] if zones else []
|
||||||
|
|
||||||
|
if cam_key not in alert_rules:
|
||||||
|
logging.debug(f"Camera '{camera}' not in alert_rules.json — event blocked")
|
||||||
|
return False
|
||||||
|
|
||||||
|
rule = alert_rules[cam_key]
|
||||||
|
|
||||||
|
if rule["labels"]:
|
||||||
|
if lbl not in rule["labels"]:
|
||||||
|
logging.debug(f"Label '{label}' not allowed for camera '{camera}' — event blocked")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if rule["ignore"]:
|
||||||
|
if lbl in rule["ignore"]:
|
||||||
|
logging.debug(f"Label '{label}' is ignored for camera '{camera}' — event blocked")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if rule["zones"]:
|
||||||
|
if not zones_check:
|
||||||
|
logging.debug(f"No zone info in event but zones filter present — event blocked")
|
||||||
|
return False
|
||||||
|
allowed_zones = [z.lower() for z in rule["zones"]]
|
||||||
|
if not any(zone in allowed_zones for zone in zones_check):
|
||||||
|
logging.debug(f"Zones {zones} not allowed for camera '{camera}' — event blocked")
|
||||||
|
return False
|
||||||
|
|
||||||
|
logging.debug(f"Event allowed for camera '{camera}', label '{label}', zones '{zones}'")
|
||||||
|
return True
|
||||||
|
|
||||||
def send_email(message, snapshot_urls, event_label, clip_url):
|
def send_email(message, snapshot_urls, event_label, clip_url):
|
||||||
try:
|
try:
|
||||||
subject = f"(Test) {event_label} detected!"
|
subject = f"(Test) {event_label} detected!"
|
||||||
@ -51,19 +95,20 @@ def send_email(message, snapshot_urls, event_label, clip_url):
|
|||||||
msg['From'] = EMAIL_FROM
|
msg['From'] = EMAIL_FROM
|
||||||
msg['To'] = ", ".join(EMAIL_TO)
|
msg['To'] = ", ".join(EMAIL_TO)
|
||||||
|
|
||||||
# Add the email body
|
|
||||||
body = f"{message}\n\nClip: {clip_url}"
|
body = f"{message}\n\nClip: {clip_url}"
|
||||||
msg.attach(MIMEText(body))
|
msg.attach(MIMEText(body))
|
||||||
|
|
||||||
# Attach snapshots to the email
|
|
||||||
for snapshot_url in snapshot_urls:
|
for snapshot_url in snapshot_urls:
|
||||||
response = requests.get(snapshot_url)
|
try:
|
||||||
response.raise_for_status()
|
logging.debug(f"Fetching snapshot: {snapshot_url}")
|
||||||
image_data = BytesIO(response.content)
|
response = requests.get(snapshot_url, timeout=5)
|
||||||
msg.attach(MIMEImage(image_data.read(), name="snapshot.jpg"))
|
response.raise_for_status()
|
||||||
|
image_data = BytesIO(response.content)
|
||||||
|
msg.attach(MIMEImage(image_data.read(), name="snapshot.jpg"))
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning(f"Failed to fetch or attach snapshot {snapshot_url}: {e}")
|
||||||
|
|
||||||
# Send the email
|
with smtplib.SMTP(SMTP_SERVER, SMTP_PORT, timeout=10) as server:
|
||||||
with smtplib.SMTP(SMTP_SERVER, SMTP_PORT) as server:
|
|
||||||
server.starttls()
|
server.starttls()
|
||||||
server.login(SMTP_USERNAME, SMTP_PASSWORD)
|
server.login(SMTP_USERNAME, SMTP_PASSWORD)
|
||||||
server.sendmail(EMAIL_FROM, EMAIL_TO, msg.as_string())
|
server.sendmail(EMAIL_FROM, EMAIL_TO, msg.as_string())
|
||||||
@ -72,42 +117,71 @@ def send_email(message, snapshot_urls, event_label, clip_url):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to send email: {e}")
|
logging.error(f"Failed to send email: {e}")
|
||||||
|
|
||||||
# Function to handle the event timeout and send email after waiting for new snapshots
|
def handle_event(event_id):
|
||||||
def handle_event(event_id, event_label, snapshot_urls):
|
logging.debug(f"Event handler started for event ID: {event_id}")
|
||||||
time.sleep(7.5) # Wait for more snapshots before sending email
|
time.sleep(7.5) # Delay to allow snapshots to accumulate
|
||||||
|
|
||||||
clip_url = f"{HOMEASSISTANT_URL}/api/frigate/notifications/{event_id}/gate/clip.mp4"
|
event_info = event_cache.get(event_id)
|
||||||
email_message = f"A {event_label} was detected.\nEvent ID: {event_id}"
|
if not event_info:
|
||||||
send_email(email_message, snapshot_urls, event_label, clip_url)
|
logging.warning(f"No cached info found for event ID: {event_id} on handle_event")
|
||||||
|
return
|
||||||
|
|
||||||
|
clip_url = f"{HOMEASSISTANT_URL}/api/frigate/notifications/{event_id}/{event_info['camera']}/clip.mp4"
|
||||||
|
email_message = f"A {event_info['event_label']} was detected on camera {event_info['camera']}.\nEvent ID: {event_id}"
|
||||||
|
|
||||||
|
send_email(email_message, event_info['snapshot_urls'], event_info['event_label'], clip_url)
|
||||||
|
|
||||||
# Remove event from cache after processing
|
|
||||||
event_cache.pop(event_id, None)
|
event_cache.pop(event_id, None)
|
||||||
|
logging.debug(f"Event ID {event_id} processed and removed from cache")
|
||||||
|
|
||||||
# MQTT message callback
|
|
||||||
def on_message(client, userdata, message):
|
def on_message(client, userdata, message):
|
||||||
try:
|
try:
|
||||||
event_data = json.loads(message.payload.decode("utf-8"))
|
event_data = json.loads(message.payload.decode("utf-8"))
|
||||||
logging.debug(f"Frigate event data: {event_data}")
|
logging.debug(f"Received MQTT message: {event_data}")
|
||||||
|
|
||||||
event_label = event_data["after"]["label"]
|
if event_data.get("type") != "new":
|
||||||
event_id = event_data["after"]["id"]
|
logging.debug("Event type is not 'new', ignoring.")
|
||||||
snapshot_url = f"{HOMEASSISTANT_URL}/api/frigate/notifications/{event_id}/snapshot.jpg"
|
return
|
||||||
|
|
||||||
|
after = event_data.get("after")
|
||||||
|
if not after:
|
||||||
|
logging.debug("No 'after' data in event, ignoring.")
|
||||||
|
return
|
||||||
|
|
||||||
|
event_label = after.get("label")
|
||||||
|
event_id = after.get("id")
|
||||||
|
camera = after.get("camera")
|
||||||
|
# Use correct zone info as list
|
||||||
|
zones = after.get("current_zones") or after.get("entered_zones") or []
|
||||||
|
|
||||||
|
if not event_label or not event_id or not camera:
|
||||||
|
logging.debug("Missing label, id or camera in event, ignoring.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not rule_allows_event(camera, event_label, zones):
|
||||||
|
logging.info(f"Event from camera '{camera}' with label '{event_label}' and zones '{zones}' blocked by alert rules.")
|
||||||
|
return
|
||||||
|
|
||||||
|
snapshot_url = f"{HOMEASSISTANT_IP}/api/frigate/notifications/{event_id}/snapshot.jpg"
|
||||||
|
|
||||||
if event_id in event_cache:
|
if event_id in event_cache:
|
||||||
event_cache[event_id]['snapshot_urls'].append(snapshot_url)
|
event_cache[event_id]['snapshot_urls'].append(snapshot_url)
|
||||||
|
logging.debug(f"Added snapshot to existing event cache: {event_id}")
|
||||||
else:
|
else:
|
||||||
event_cache[event_id] = {
|
event_cache[event_id] = {
|
||||||
'event_label': event_label,
|
'event_label': event_label,
|
||||||
|
'camera': camera,
|
||||||
'snapshot_urls': [snapshot_url],
|
'snapshot_urls': [snapshot_url],
|
||||||
'timer': threading.Thread(target=handle_event, args=(event_id, event_label, [snapshot_url]))
|
'timer': threading.Thread(target=handle_event, args=(event_id,))
|
||||||
}
|
}
|
||||||
event_cache[event_id]['timer'].start()
|
event_cache[event_id]['timer'].start()
|
||||||
|
logging.debug(f"Started new event handler thread for event ID: {event_id}")
|
||||||
|
|
||||||
|
logging.info(f"Event processed: {event_label} - Event ID: {event_id} from camera: {camera} Zones: {zones}")
|
||||||
|
|
||||||
logging.info(f"Event processed: {event_label} - Event ID: {event_id}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error processing MQTT message: {e}")
|
logging.error(f"Error processing MQTT message: {e}")
|
||||||
|
|
||||||
# MQTT connection callback
|
|
||||||
def on_connect(client, userdata, flags, rc):
|
def on_connect(client, userdata, flags, rc):
|
||||||
if rc == 0:
|
if rc == 0:
|
||||||
logging.info("Connected to MQTT broker successfully")
|
logging.info("Connected to MQTT broker successfully")
|
||||||
@ -115,7 +189,6 @@ def on_connect(client, userdata, flags, rc):
|
|||||||
else:
|
else:
|
||||||
logging.error(f"Failed to connect to MQTT broker. Return code: {rc}")
|
logging.error(f"Failed to connect to MQTT broker. Return code: {rc}")
|
||||||
|
|
||||||
# MQTT setup
|
|
||||||
def connect_mqtt():
|
def connect_mqtt():
|
||||||
client = mqtt.Client()
|
client = mqtt.Client()
|
||||||
client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD)
|
client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD)
|
||||||
@ -130,12 +203,11 @@ def connect_mqtt():
|
|||||||
logging.error(f"Error during MQTT connection: {e}")
|
logging.error(f"Error during MQTT connection: {e}")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
# Print or log the warning message when the script is run
|
|
||||||
print("WARNING: USE THIS FOR TESTING AND DEBUGGING ONLY!")
|
print("WARNING: USE THIS FOR TESTING AND DEBUGGING ONLY!")
|
||||||
logging.warning("WARNING: USE THIS FOR TESTING AND DEBUGGING ONLY!")
|
logging.warning("WARNING: USE THIS FOR TESTING AND DEBUGGING ONLY!")
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
print("WARNING: USE THIS FOR TESTING AND DEBUGGING ONLY!")
|
print("WARNING: USE THIS FOR TESTING AND DEBUGGING ONLY!")
|
||||||
logging.warning("WARNING: USE THIS FOR TESTING AND DEBUGGING ONLY!")
|
logging.warning("WARNING: USE THIS FOR TESTING AND DEBUGGING ONLY!")
|
||||||
|
|
||||||
logging.info("Starting Frigate Event Notifier...")
|
logging.info("Starting Frigate Event Notifier...")
|
||||||
connect_mqtt()
|
connect_mqtt()
|
||||||
202
main.py
202
main.py
@ -1,4 +1,3 @@
|
|||||||
# Import necessary dependencies
|
|
||||||
import paho.mqtt.client as mqtt
|
import paho.mqtt.client as mqtt
|
||||||
import smtplib
|
import smtplib
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
@ -9,8 +8,15 @@ import json
|
|||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
|
import logging
|
||||||
|
|
||||||
# Load settings from config.json
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s [%(levelname)s] %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Load config
|
||||||
with open('config.json', 'r') as f:
|
with open('config.json', 'r') as f:
|
||||||
config = json.load(f)
|
config = json.load(f)
|
||||||
|
|
||||||
@ -21,17 +27,73 @@ SMTP_PASSWORD = config["smtp"]["password"]
|
|||||||
EMAIL_FROM = config["smtp"]["from"]
|
EMAIL_FROM = config["smtp"]["from"]
|
||||||
EMAIL_TO = config["smtp"]["to"]
|
EMAIL_TO = config["smtp"]["to"]
|
||||||
HOMEASSISTANT_URL = config["homeassistant_url"]
|
HOMEASSISTANT_URL = config["homeassistant_url"]
|
||||||
|
HOMEASSISTANT_IP = config.get("homeassistant_ip", HOMEASSISTANT_URL)
|
||||||
|
|
||||||
# MQTT configuration
|
|
||||||
MQTT_BROKER_IP = config["mqtt"]["broker_ip"]
|
MQTT_BROKER_IP = config["mqtt"]["broker_ip"]
|
||||||
MQTT_PORT = config["mqtt"]["port"]
|
MQTT_PORT = config["mqtt"]["port"]
|
||||||
MQTT_USERNAME = config["mqtt"]["username"]
|
MQTT_USERNAME = config["mqtt"]["username"]
|
||||||
MQTT_PASSWORD = config["mqtt"]["password"]
|
MQTT_PASSWORD = config["mqtt"]["password"]
|
||||||
|
|
||||||
# Dictionary to track event IDs and email state
|
# Load alert rules
|
||||||
|
try:
|
||||||
|
with open("alert_rules.json", "r") as f:
|
||||||
|
alert_rules_raw = json.load(f)
|
||||||
|
logger.info(f"Loaded alert_rules.json: {alert_rules_raw}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load alert_rules.json, no events will be processed: {e}")
|
||||||
|
alert_rules_raw = {}
|
||||||
|
|
||||||
|
alert_rules = {}
|
||||||
|
for cam, rules in alert_rules_raw.items():
|
||||||
|
alert_rules[cam.lower()] = {
|
||||||
|
"labels": [lbl.lower() for lbl in rules.get("labels", [])],
|
||||||
|
"ignore": [lbl.lower() for lbl in rules.get("ignore", [])],
|
||||||
|
"zones": [zone.lower() for zone in rules.get("zones", [])]
|
||||||
|
}
|
||||||
|
|
||||||
event_cache = {}
|
event_cache = {}
|
||||||
|
|
||||||
# Function to send email with attachment and clip link
|
|
||||||
|
def rule_allows_event(camera, label, zones):
|
||||||
|
cam_key = camera.lower()
|
||||||
|
lbl = label.lower()
|
||||||
|
zones_check = [z.lower() for z in zones] if zones else []
|
||||||
|
|
||||||
|
if cam_key not in alert_rules:
|
||||||
|
return False
|
||||||
|
|
||||||
|
rule = alert_rules[cam_key]
|
||||||
|
|
||||||
|
if rule["labels"] and lbl not in rule["labels"]:
|
||||||
|
return False
|
||||||
|
if rule["ignore"] and lbl in rule["ignore"]:
|
||||||
|
return False
|
||||||
|
if rule["zones"]:
|
||||||
|
if not zones_check:
|
||||||
|
return False
|
||||||
|
allowed_zones = [z.lower() for z in rule["zones"]]
|
||||||
|
if not any(zone in allowed_zones for zone in zones_check):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_snapshot_with_retry(snapshot_url, retries=5, delay=1):
|
||||||
|
"""
|
||||||
|
Try to fetch a valid snapshot, retrying if it fails.
|
||||||
|
"""
|
||||||
|
for attempt in range(retries):
|
||||||
|
try:
|
||||||
|
response = requests.get(snapshot_url, timeout=5)
|
||||||
|
response.raise_for_status()
|
||||||
|
if 'image' in response.headers.get('Content-Type', ''):
|
||||||
|
return response.content
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Snapshot fetch failed (attempt {attempt+1}/{retries}): {e}")
|
||||||
|
time.sleep(delay)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def send_email(message, snapshot_urls, event_label, clip_url):
|
def send_email(message, snapshot_urls, event_label, clip_url):
|
||||||
subject = f"{event_label} detected!"
|
subject = f"{event_label} detected!"
|
||||||
msg = MIMEMultipart()
|
msg = MIMEMultipart()
|
||||||
@ -39,80 +101,122 @@ def send_email(message, snapshot_urls, event_label, clip_url):
|
|||||||
msg['From'] = EMAIL_FROM
|
msg['From'] = EMAIL_FROM
|
||||||
msg['To'] = ", ".join(EMAIL_TO)
|
msg['To'] = ", ".join(EMAIL_TO)
|
||||||
|
|
||||||
# Add the email body
|
|
||||||
body = f"{message}\n\nClip: {clip_url}"
|
body = f"{message}\n\nClip: {clip_url}"
|
||||||
msg.attach(MIMEText(body))
|
msg.attach(MIMEText(body))
|
||||||
|
|
||||||
# Attach snapshots to the email
|
|
||||||
for snapshot_url in snapshot_urls:
|
for snapshot_url in snapshot_urls:
|
||||||
response = requests.get(snapshot_url)
|
image_bytes = fetch_snapshot_with_retry(snapshot_url)
|
||||||
image_data = BytesIO(response.content)
|
if image_bytes:
|
||||||
msg.attach(MIMEImage(image_data.read(), name="snapshot.jpg"))
|
msg.attach(MIMEImage(image_bytes, name="snapshot.jpg"))
|
||||||
|
|
||||||
# Send the email
|
try:
|
||||||
with smtplib.SMTP(SMTP_SERVER, SMTP_PORT) as server:
|
with smtplib.SMTP(SMTP_SERVER, SMTP_PORT, timeout=10) as server:
|
||||||
server.starttls()
|
server.starttls()
|
||||||
server.login(SMTP_USERNAME, SMTP_PASSWORD)
|
server.login(SMTP_USERNAME, SMTP_PASSWORD)
|
||||||
server.sendmail(EMAIL_FROM, EMAIL_TO, msg.as_string())
|
server.sendmail(EMAIL_FROM, EMAIL_TO, msg.as_string())
|
||||||
|
logger.info(f"Email sent: {subject} to {', '.join(EMAIL_TO)}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send email: {e}")
|
||||||
|
|
||||||
# Print the status of the email
|
|
||||||
print(f"Email sent to {', '.join(EMAIL_TO)} with subject: {subject}")
|
|
||||||
|
|
||||||
# Function to handle the event timeout and send email after waiting for new snapshots
|
def handle_event(event_id):
|
||||||
def handle_event(event_id, event_label, snapshot_urls):
|
if event_id not in event_cache:
|
||||||
time.sleep(10) # Wait for more snapshots before sending email
|
return
|
||||||
|
|
||||||
clip_url = f"{HOMEASSISTANT_URL}/api/frigate/notifications/{event_id}/gate/clip.mp4"
|
event_info = event_cache[event_id]
|
||||||
email_message = f"A {event_label} was detected.\nEvent ID: {event_id}"
|
|
||||||
send_email(email_message, snapshot_urls, event_label, clip_url)
|
|
||||||
|
|
||||||
# Print message after email is sent
|
# Don’t send again if already emailed
|
||||||
print(f"Sent email for event: {event_label} (Event ID: {event_id})")
|
if event_info.get("emailed"):
|
||||||
|
logger.debug(f"Skipping already emailed event: {event_id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
clip_url = f"{HOMEASSISTANT_URL}/api/frigate/notifications/{event_id}/{event_info['camera']}/clip.mp4"
|
||||||
|
message = f"A {event_info['event_label']} was detected on camera: {event_info['camera']}.\nEvent ID: {event_id}"
|
||||||
|
|
||||||
|
send_email(message, event_info['snapshot_urls'], event_info['event_label'], clip_url)
|
||||||
|
|
||||||
|
event_cache[event_id]['emailed'] = True
|
||||||
|
logger.info(f"Processed and emailed event: {event_id}")
|
||||||
|
|
||||||
# Remove event from cache after processing
|
|
||||||
event_cache.pop(event_id, None)
|
|
||||||
|
|
||||||
# MQTT message callback
|
|
||||||
def on_message(client, userdata, message):
|
def on_message(client, userdata, message):
|
||||||
try:
|
try:
|
||||||
event_data = json.loads(message.payload.decode("utf-8"))
|
event_data = json.loads(message.payload.decode("utf-8"))
|
||||||
event_label = event_data["after"]["label"]
|
if event_data.get("type") != "new":
|
||||||
event_id = event_data["after"]["id"]
|
return
|
||||||
snapshot_url = f"{HOMEASSISTANT_URL}/api/frigate/notifications/{event_id}/snapshot.jpg"
|
|
||||||
|
|
||||||
if event_id in event_cache:
|
after = event_data.get("after")
|
||||||
event_cache[event_id]['snapshot_urls'].append(snapshot_url)
|
if not after:
|
||||||
else:
|
return
|
||||||
|
|
||||||
|
event_label = after.get("label")
|
||||||
|
event_id = after.get("id")
|
||||||
|
camera = after.get("camera")
|
||||||
|
zones = after.get("current_zones") or after.get("entered_zones") or []
|
||||||
|
|
||||||
|
if not event_label or not event_id or not camera:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not rule_allows_event(camera, event_label, zones):
|
||||||
|
logger.info(f"Event from camera '{camera}' with label '{event_label}' and zones '{zones}' blocked by alert rules.")
|
||||||
|
return
|
||||||
|
|
||||||
|
snapshot_url = f"{HOMEASSISTANT_IP}/api/frigate/notifications/{event_id}/snapshot.jpg"
|
||||||
|
|
||||||
|
if event_id not in event_cache:
|
||||||
|
# First time seeing this event
|
||||||
event_cache[event_id] = {
|
event_cache[event_id] = {
|
||||||
'event_label': event_label,
|
'event_label': event_label,
|
||||||
|
'camera': camera,
|
||||||
'snapshot_urls': [snapshot_url],
|
'snapshot_urls': [snapshot_url],
|
||||||
'timer': threading.Thread(target=handle_event, args=(event_id, event_label, [snapshot_url]))
|
'emailed': False
|
||||||
}
|
}
|
||||||
event_cache[event_id]['timer'].start()
|
# Send email immediately in a thread
|
||||||
|
threading.Thread(target=handle_event, args=(event_id,), daemon=True).start()
|
||||||
|
else:
|
||||||
|
# Already seen this event → just collect snapshots
|
||||||
|
event_cache[event_id]['snapshot_urls'].append(snapshot_url)
|
||||||
|
|
||||||
# Print when motion is detected
|
logger.info(f"Received event: {event_label} from {camera} (Event ID: {event_id}, Zones: {zones})")
|
||||||
print(f"Motion detected: {event_label} (Event ID: {event_id})")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
pass # Ignore errors in message processing
|
logger.error(f"Error processing MQTT message: {e}")
|
||||||
|
|
||||||
# MQTT connection callback
|
|
||||||
def on_connect(client, userdata, flags, rc):
|
def on_connect(client, userdata, flags, rc, properties=None):
|
||||||
if rc == 0:
|
if rc != 0:
|
||||||
|
logger.error(f"MQTT connection failed with code {rc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if userdata.get("first_connect", True):
|
||||||
client.subscribe("frigate/events")
|
client.subscribe("frigate/events")
|
||||||
|
logger.info("Connected to MQTT broker and subscribed to frigate/events")
|
||||||
|
userdata["first_connect"] = False
|
||||||
|
else:
|
||||||
|
logger.debug("Reconnected to MQTT broker")
|
||||||
|
|
||||||
|
|
||||||
# MQTT setup
|
|
||||||
def connect_mqtt():
|
def connect_mqtt():
|
||||||
client = mqtt.Client()
|
client = mqtt.Client(
|
||||||
|
client_id="frigate_smtp",
|
||||||
|
protocol=mqtt.MQTTv5,
|
||||||
|
userdata={"first_connect": True}
|
||||||
|
)
|
||||||
client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD)
|
client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD)
|
||||||
client.on_connect = on_connect
|
client.on_connect = on_connect
|
||||||
client.on_message = on_message
|
client.on_message = on_message
|
||||||
|
|
||||||
try:
|
while True:
|
||||||
client.connect(MQTT_BROKER_IP, MQTT_PORT, 60)
|
try:
|
||||||
client.loop_forever()
|
logger.info("Connecting to MQTT broker...")
|
||||||
except Exception as e:
|
client.connect(MQTT_BROKER_IP, MQTT_PORT, 60)
|
||||||
pass # Ignore connection errors
|
client.loop_start()
|
||||||
|
while True:
|
||||||
|
time.sleep(1)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"MQTT connection failed: {e}. Retrying in 5 seconds...")
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
connect_mqtt()
|
connect_mqtt()
|
||||||
|
|||||||
Reference in New Issue
Block a user