Compare commits

..

71 Commits

Author SHA1 Message Date
Ninluc e68ffd9b8b Fix
Build, push image, and notify Watchtower / build-image (push) Successful in 43s
Build, push image, and notify Watchtower / notify (push) Successful in 10s
2026-08-25 20:07:38 +02:00
Ninluc 49fd566457 Idk
Build, push image, and notify Watchtower / build-image (push) Successful in 48s
Build, push image, and notify Watchtower / notify (push) Successful in 10s
2026-08-25 20:01:36 +02:00
Ninluc dd37465546 OOpsie
Build, push image, and notify Watchtower / build-image (push) Successful in 50s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-25 19:23:10 +02:00
Ninluc 3753f57041 Small things because fuck sd cards
Build, push image, and notify Watchtower / build-image (push) Successful in 3m22s
Build, push image, and notify Watchtower / notify (push) Successful in 1m24s
2026-08-25 19:16:50 +02:00
Ninluc 688fd26db8 Changed interface 2026-08-25 19:16:41 +02:00
Ninluc 4d7706f8e1 Adde command to setuot thread network 2026-08-25 19:16:13 +02:00
Ninluc efbc147f48 Default debug to false 2026-08-25 19:16:01 +02:00
Ninluc 4085305039 Save Analyzed image 2026-08-25 19:15:52 +02:00
Ninluc fed99772fc Don't override microwave ID
Build, push image, and notify Watchtower / build-image (push) Successful in 46s
Build, push image, and notify Watchtower / notify (push) Successful in 8s
2026-08-24 15:44:22 +02:00
Ninluc 204f271fc2 Fix global
Build, push image, and notify Watchtower / build-image (push) Successful in 42s
Build, push image, and notify Watchtower / notify (push) Successful in 14s
2026-08-23 20:04:26 +02:00
Ninluc 5c60de3f03 Fix buzzer 2026-08-23 20:04:20 +02:00
Ninluc 8a71d4eaa7 :(
Build, push image, and notify Watchtower / build-image (push) Successful in 37s
Build, push image, and notify Watchtower / notify (push) Successful in 12s
2026-08-23 18:52:05 +02:00
Ninluc eb80d13300 :(
Build, push image, and notify Watchtower / build-image (push) Successful in 39s
Build, push image, and notify Watchtower / notify (push) Successful in 12s
2026-08-23 18:43:16 +02:00
Ninluc 6e2f849ee2 :(
Build, push image, and notify Watchtower / build-image (push) Successful in 39s
Build, push image, and notify Watchtower / notify (push) Successful in 19s
2026-08-23 18:38:41 +02:00
Ninluc 68250e8069 IDC
Build, push image, and notify Watchtower / build-image (push) Successful in 51s
Build, push image, and notify Watchtower / notify (push) Successful in 12s
2026-08-23 18:30:00 +02:00
Ninluc fe31ac978a Dissociate DEBUG variables 2026-08-23 17:48:04 +02:00
Ninluc 3c50fc4d0e Follow the damn prompt CJ
Build, push image, and notify Watchtower / build-image (push) Successful in 39s
Build, push image, and notify Watchtower / notify (push) Successful in 10s
2026-08-23 17:45:13 +02:00
Ninluc 3aed0e5f00 Fuck the blur
Build, push image, and notify Watchtower / build-image (push) Successful in 42s
Build, push image, and notify Watchtower / notify (push) Successful in 16s
2026-08-23 17:36:57 +02:00
Ninluc f55336a636 Tried my own
Build, push image, and notify Watchtower / build-image (push) Successful in 47s
Build, push image, and notify Watchtower / notify (push) Successful in 15s
2026-08-23 17:33:24 +02:00
Ninluc c58ae4603e Skip sending implicit new state
Build, push image, and notify Watchtower / build-image (push) Successful in 39s
Build, push image, and notify Watchtower / notify (push) Successful in 11s
2026-08-23 17:07:18 +02:00
Ninluc e7f6374200 Skip food safety alert for webex and sms 2026-08-23 17:06:59 +02:00
Ninluc 9053351674 idk
Build, push image, and notify Watchtower / build-image (push) Successful in 37s
Build, push image, and notify Watchtower / notify (push) Successful in 9s
2026-08-23 16:51:45 +02:00
Ninluc 1f53e7e6c3 Better prompt again ?
Build, push image, and notify Watchtower / build-image (push) Successful in 34s
Build, push image, and notify Watchtower / notify (push) Successful in 15s
2026-08-23 16:51:27 +02:00
Ninluc 7c35b52dbd Precision in prompt + better camera
Build, push image, and notify Watchtower / build-image (push) Successful in 38s
Build, push image, and notify Watchtower / notify (push) Successful in 11s
2026-08-23 16:48:30 +02:00
Ninluc 0f576467e1 send_reliably if cooking state update
Build, push image, and notify Watchtower / build-image (push) Successful in 40s
Build, push image, and notify Watchtower / notify (push) Successful in 15s
2026-08-23 16:19:09 +02:00
Ninluc 9d1a1f4c78 Try with normal image 2026-08-23 16:18:53 +02:00
Ninluc 701e7f1491 Reapply "Fix move to new messages"
This reverts commit e652831ce5.
2026-08-23 16:14:04 +02:00
Ninluc e652831ce5 Revert "Fix move to new messages"
This reverts commit 3bead59b23.
2026-08-23 16:12:15 +02:00
Ninluc ebc28546bc Better camera settings 2026-08-23 16:10:11 +02:00
Ninluc a51bfc1388 Bigger gaps 2026-08-23 16:10:00 +02:00
Ninluc 7082ea3f8b Better camera settings + better rpompt
Build, push image, and notify Watchtower / build-image (push) Successful in 45s
Build, push image, and notify Watchtower / notify (push) Successful in 12s
2026-08-23 14:32:17 +02:00
Ninluc e802386adf Set to tidle when error requesting cooking plan 2026-08-23 14:22:59 +02:00
Ninluc 289124b7f8 Change State when microwave state is Idel 2026-08-23 14:22:39 +02:00
Ninluc 3bead59b23 Fix move to new messages 2026-08-23 13:04:16 +02:00
Ninluc 467740ba84 Better prompt
Build, push image, and notify Watchtower / build-image (push) Successful in 40s
Build, push image, and notify Watchtower / notify (push) Successful in 9s
2026-08-23 12:51:55 +02:00
Ninluc 29c943ca5a Smooth image to remove noise
Build, push image, and notify Watchtower / build-image (push) Successful in 38s
Build, push image, and notify Watchtower / notify (push) Successful in 11s
2026-08-23 12:51:14 +02:00
Ninluc c130566156 Accept microwaves state IDLE 2026-08-23 12:51:05 +02:00
Ninluc 178457e199 Fix imports for micropython 2026-08-23 12:50:50 +02:00
Ninluc c860afc609 Changed IDs 2026-08-23 12:50:27 +02:00
Ninluc 9562bb7315 Forgot to remove that
Build, push image, and notify Watchtower / build-image (push) Successful in 1m19s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-23 12:39:44 +02:00
Ninluc a769efa8e0 Fix import
Build, push image, and notify Watchtower / build-image (push) Successful in 1m7s
Build, push image, and notify Watchtower / notify (push) Successful in 18s
2026-08-23 12:37:06 +02:00
Ninluc 3711928c7d Enhance image before ai processing
Build, push image, and notify Watchtower / build-image (push) Successful in 41s
Build, push image, and notify Watchtower / notify (push) Successful in 14s
2026-08-23 12:33:39 +02:00
Ninluc 6b8c1688a6 Send alerts and reset 2026-08-23 12:33:25 +02:00
Ninluc 755ffdad46 Send as an alert
Build, push image, and notify Watchtower / build-image (push) Successful in 55s
Build, push image, and notify Watchtower / notify (push) Successful in 14s
2026-08-23 11:32:42 +02:00
Ninluc 29fcb26eae No privilegied container 2026-08-23 11:32:34 +02:00
Ninluc 8b3c1d6b63 Added Fake bowl dish name for documentation 2026-08-23 11:18:29 +02:00
Ninluc c39b811a3c Lower noise buffer for ultrasonic ranger
Build, push image, and notify Watchtower / build-image (push) Successful in 41s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-23 11:05:04 +02:00
Ninluc b0a6c4d2ec Bettter safety check 2026-08-23 11:04:38 +02:00
Ninluc 0b1971ed0b Forgot https 2026-08-23 10:00:20 +02:00
Ninluc d6951cbbcf Fix Camera cm per pixels 2026-08-23 10:00:15 +02:00
Ninluc c6cec3dede add to non-authenticated routes
Build, push image, and notify Watchtower / build-image (push) Successful in 34s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-23 09:36:08 +02:00
Ninluc e67a13b4c8 Fix jobs ?
Build, push image, and notify Watchtower / build-image (push) Successful in 36s
Build, push image, and notify Watchtower / notify (push) Successful in 15s
2026-08-23 09:30:31 +02:00
Ninluc 0d394eca1e Only use Segoie UI 2026-08-23 09:27:54 +02:00
Ninluc c76379230b Remove false positives 2026-08-23 09:27:44 +02:00
Ninluc e042162a77 Fix api server host 2026-08-23 09:27:34 +02:00
Ninluc f167f4a2cd Set final product physical properties 2026-08-23 09:27:19 +02:00
Ninluc 7add88f154 Added logo file 2026-08-23 09:24:53 +02:00
Ninluc bb5327fb93 Technician Debug Web Server
Build, push image, and notify Watchtower / build-image (push) Successful in 42s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-19 17:04:33 +02:00
Ninluc 022c45edee Fix API key 2026-08-19 17:04:10 +02:00
Ninluc d7833100a5 RFID reading and authorization
Build, push image, and notify Watchtower / build-image (push) Successful in 43s
Build, push image, and notify Watchtower / notify (push) Successful in 12s
2026-08-19 15:51:13 +02:00
Ninluc db173af1c9 Only node-red is allowed to run CVE audit 2026-08-19 15:47:44 +02:00
Ninluc d0066a9d6d Authentificate to API
Build, push image, and notify Watchtower / build-image (push) Successful in 40s
Build, push image, and notify Watchtower / notify (push) Successful in 8s
2026-08-18 18:40:32 +02:00
Ninluc f9309c4dbc Authentication and Odata compliance
Build, push image, and notify Watchtower / build-image (push) Successful in 39s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-18 17:04:02 +02:00
Ninluc e69bbc6b45 Debug jobs
Build, push image, and notify Watchtower / build-image (push) Successful in 44s
Build, push image, and notify Watchtower / notify (push) Successful in 10s
2026-08-18 15:50:15 +02:00
Ninluc 88c3de6ab7 Shodan and jobs
Build, push image, and notify Watchtower / build-image (push) Successful in 3m25s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-18 15:48:37 +02:00
Ninluc 2c0776bdaa Logs the read GPS line
Build, push image, and notify Watchtower / build-image (push) Successful in 1m3s
Build, push image, and notify Watchtower / notify (push) Successful in 12s
2026-08-18 13:19:02 +02:00
Ninluc da93eaa604 Added dashboard link 2026-08-18 13:18:50 +02:00
Ninluc 608e07cb92 We rolling back ? 2026-08-17 23:21:21 +02:00
Ninluc 2ee70861cd Better GPS read 2026-08-17 23:14:18 +02:00
Ninluc af556fde58 Wait more than 2 seconds for better DHT sensor reads 2026-08-17 23:14:10 +02:00
Ninluc bcc3670aff Do not timeout if only asked to you
Build, push image, and notify Watchtower / build-image (push) Successful in 40s
Build, push image, and notify Watchtower / notify (push) Successful in 8s
2026-08-17 23:03:34 +02:00
38 changed files with 1636 additions and 505 deletions
+3 -1
View File
@@ -1,4 +1,6 @@
`export PYTHONPATH=/home/pi/SmartWave` `export PYTHONPATH=/home/pi/SmartWave`
`export PYTHONPATH=/home/ninluc/Documents/school/IoT/smartWave` `export PYTHONPATH=/home/ninluc/Documents/school/IoT/smartWave`
https://red.smartwave.matthiasg.dev/dashboard
+8 -8
View File
@@ -911,15 +911,15 @@ class EdamamAPI:
""") """)
url = "https://api.edamam.com/api/vision/v1/nutrients" url = "https://api.edamam.com/api/vision/v1/nutrients?app_id=09cb5bd7&app_key=8b2730f3c7f4f075cc939da3fa0c49fb&beta=true&servings=1"
# Query parameters as specified by the YAML schema # Query parameters as specified by the YAML schema
params = { # params = {
"app_id": EDAMAM_APP_ID__FOOD, # "app_id": EDAMAM_APP_ID__FOOD,
"app_key": EDAMAM_APP_KEY__FOOD, # "app_key": EDAMAM_APP_KEY__FOOD,
"beta": "true" # Recommended since vision is in Beta # "beta": "true" # Recommended since vision is in Beta
} # }
print("params:", params) # print("params:", params)
# Specify the media type in the header # Specify the media type in the header
headers = { headers = {
@@ -932,7 +932,7 @@ class EdamamAPI:
response = requests.post( response = requests.post(
url, url,
params=params, # params=params,
headers=headers, headers=headers,
data=raw_image_bytes data=raw_image_bytes
) )
+134
View File
@@ -0,0 +1,134 @@
import os
import requests
from typing import Dict, Any, Optional
class ShodanAuditor:
"""Service class to handle Shodan API security audits and IP profiling."""
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key or os.getenv("SHODAN_API_KEY", "")
self.base_url = "https://api.shodan.io"
def get_public_ip(self) -> Optional[str]:
"""Utility method to discover the server's public IPv4 address."""
try:
res = requests.get("https://api.ipify.org?format=json", timeout=5)
return res.json().get("ip")
except Exception:
return None
def _query_host(self, ip: str) -> Dict[str, Any]:
"""Internal helper to query Shodan's Host API endpoint."""
if not self.api_key:
return {"error": "Missing Shodan API key"}
url = f"{self.base_url}/shodan/host/{ip}?key={self.api_key}"
try:
response = requests.get(url, timeout=8)
if response.status_code == 404:
return {"indexed": False, "ip": ip}
if response.status_code != 200:
return {"error": f"Shodan API error HTTP {response.status_code}"}
data = response.json()
data["indexed"] = True
return data
except requests.RequestException as e:
return {"error": str(e)}
def audit_server_vulnerabilities(self, target_ip: Optional[str] = None) -> Dict[str, Any]:
"""
Audits the server for reported CVEs, exposed services, and SSL details.
:param target_ip: IP to scan (defaults to server's own public IP)
"""
ip = target_ip or self.get_public_ip()
if not ip:
return {"status": "error", "message": "Unable to resolve target IP"}
data = self._query_host(ip)
if "error" in data:
return {"status": "error", "message": data["error"], "target_ip": ip}
if not data.get("indexed"):
return {
"status": "PASS",
"target_ip": ip,
"message": "IP is not indexed by Shodan (low public exposure)",
"vulnerabilities": [],
"open_ports": []
}
# Extract CVEs
vulns = data.get("vulns", [])
# Extract SSL/TLS info from HTTP services if available
ssl_info = []
for service in data.get("data", []):
if "ssl" in service:
ssl_details = service["ssl"]
ssl_info.append({
"port": service.get("port"),
"cert_expired": ssl_details.get("cert", {}).get("expired", False),
"cipher": ssl_details.get("cipher", {}).get("name"),
"version": ssl_details.get("versions")
})
return {
"status": "FAIL" if vulns else "PASS",
"target_ip": ip,
"hostnames": data.get("hostnames", []),
"open_ports": data.get("ports", []),
"vulnerabilities": vulns,
"vuln_count": len(vulns),
"ssl_certificates": ssl_info,
"last_shodan_update": data.get("last_update")
}
def profile_client_ip(self, client_ip: str) -> Dict[str, Any]:
"""
Profiles a incoming client (microwave) public IP address.
Checks if the client network has exposed admin panels, Telnet, or CVEs.
:param client_ip: Public IP of the connecting device/router
"""
# Skip local/private network IPs
if client_ip in ["127.0.0.1", "localhost"] or client_ip.startswith(("10.", "192.168.", "172.16.")):
return {"status": "skipped", "reason": "Local/Private IP"}
data = self._query_host(client_ip)
if "error" in data or not data.get("indexed"):
return {
"client_ip": client_ip,
"risk_level": "LOW",
"indexed": False,
"summary": "Client IP not indexed on Shodan"
}
ports = data.get("ports", [])
vulns = data.get("vulns", [])
# Check for high-risk exposed router/device ports
high_risk_ports = [23, 80, 8080, 8443, 7547] # Telnet, Web UI, TR-069
flagged_ports = [p for p in ports if p in high_risk_ports]
# Calculate basic risk level
if vulns or 23 in ports:
risk_level = "HIGH"
elif flagged_ports:
risk_level = "MEDIUM"
else:
risk_level = "LOW"
return {
"client_ip": client_ip,
"risk_level": risk_level,
"indexed": True,
"open_ports": ports,
"flagged_router_ports": flagged_ports,
"vulnerabilities": vulns,
"org": data.get("org", "Unknown ISP")
}
+19 -1
View File
@@ -23,4 +23,22 @@ curl -X POST "https://smartwave.matthiasg.dev/alert" \
}, },
"logs": [] "logs": []
}' }'
``` ```
## Jobs
### Trigger Server CVE Audit
`curl https://smartwave.matthiasg.dev/debug/run-job/server_cve_audit_job`
### Trigger Client IP Audit
`curl https://smartwave.matthiasg.dev/debug/run-job/client_ip_audit_job`
### Trigger Telemetry Request
`curl https://smartwave.matthiasg.dev/debug/run-job/request_telemetry_job`
# Faux bols
- Riz
- Pot Beef Burgundy
+509 -197
View File
@@ -1,19 +1,26 @@
import os import os
import asyncio
import base64
import uuid
from datetime import datetime, timedelta, timezone
import sys import sys
import json import json
from flask import Flask, request, jsonify, current_app import uuid
from pymongo import MongoClient import base64
from APIs import generate, EdamamAPI import asyncio
import fcntl
import atexit
import re
from datetime import datetime, timedelta, timezone
from flask import Flask, Response, request, jsonify, current_app, abort, g
from pymongo import MongoClient, ASCENDING, DESCENDING
from apscheduler.schedulers.background import BackgroundScheduler
from APIs import generate
from APIs.mqtt import send_command from APIs.mqtt import send_command
from APIs.webex import WebexManager from APIs.webex import WebexManager
from APIs.shodan import ShodanAuditor
from APIs.twilio import send_alert_sms
from image_enhancer import enhance_image_for_ai
from microwaveCookPlanner import MicrowaveCookPlanner from microwaveCookPlanner import MicrowaveCookPlanner
import safety_checker import safety_checker
import pymongo from jobs import job_server_cve_audit, job_client_ip_audit, job_request_telemetry
from APIs.twilio import send_alert_sms
sys.path.insert(0, '..') sys.path.insert(0, '..')
try: try:
@@ -32,13 +39,26 @@ MONGO_URI = os.getenv("MONGO_URI", "mongodb://localhost:27017/")
client = MongoClient(MONGO_URI) client = MongoClient(MONGO_URI)
db = client["microwave_network_db"] db = client["microwave_network_db"]
# Database Collections
client_collection = db["client_data"] client_collection = db["client_data"]
cooking_collection = db["cooking_parameters"] cooking_collection = db["cooking_parameters"]
telemetry_collection = db["telemetry_data"] telemetry_collection = db["telemetry_data"]
alert_collection = db["alert_data"] alert_collection = db["alert_data"]
webex_tokens_collection = db["webex_tokens"] # Collection for Webex OAuth tokens webex_tokens_collection = db["webex_tokens"]
device_ip_collection = db["device_ips"]
cve_audit_results = db["cve_audit_results"]
rfid_cards_collection = db["rfid_cards"]
# Webex Credentials & Configuration from Environment Variables # Initialize Database Indexes
def init_db_indexes():
device_ip_collection.create_index([("received_at", DESCENDING)])
device_ip_collection.create_index([("client_ip", ASCENDING)])
alert_collection.create_index([("orchestrator_id", ASCENDING), ("created_at", DESCENDING)])
client_collection.create_index([("devices.device_id", ASCENDING)])
init_db_indexes()
# Environment Configurations
WEBEX_CLIENT_ID = os.getenv("WEBEX_CLIENT_ID", "YOUR_WEBEX_CLIENT_ID") WEBEX_CLIENT_ID = os.getenv("WEBEX_CLIENT_ID", "YOUR_WEBEX_CLIENT_ID")
WEBEX_CLIENT_SECRET = os.getenv("WEBEX_CLIENT_SECRET", "YOUR_WEBEX_CLIENT_SECRET") WEBEX_CLIENT_SECRET = os.getenv("WEBEX_CLIENT_SECRET", "YOUR_WEBEX_CLIENT_SECRET")
WEBEX_REDIRECT_URI = os.getenv("WEBEX_REDIRECT_URI", "https://smartwave.matthiasg.dev/oauth/callback") WEBEX_REDIRECT_URI = os.getenv("WEBEX_REDIRECT_URI", "https://smartwave.matthiasg.dev/oauth/callback")
@@ -51,10 +71,9 @@ CAMERA_IMAGE_DIR = "storage/dishPhotos"
os.makedirs(CAMERA_IMAGE_DIR, exist_ok=True) os.makedirs(CAMERA_IMAGE_DIR, exist_ok=True)
# --------------------------------------------------------- # ---------------------------------------------------------
# Classes # Integrations Setup
# --------------------------------------------------------- # ---------------------------------------------------------
microwave_cook_planner = MicrowaveCookPlanner() microwave_cook_planner = MicrowaveCookPlanner()
# Instantiate Webex Manager
webex_manager = WebexManager( webex_manager = WebexManager(
db_collection=webex_tokens_collection, db_collection=webex_tokens_collection,
client_id=WEBEX_CLIENT_ID, client_id=WEBEX_CLIENT_ID,
@@ -63,24 +82,112 @@ webex_manager = WebexManager(
team_id=WEBEX_TEAM_ID, team_id=WEBEX_TEAM_ID,
user_id=WEBEX_NINLUC_ID user_id=WEBEX_NINLUC_ID
) )
shodan_auditor = ShodanAuditor()
# --------------------------------------------------------- # ---------------------------------------------------------
# OAuth Routes # Cron Scheduler (Multi-Worker Safe)
# --------------------------------------------------------- # ---------------------------------------------------------
scheduler = BackgroundScheduler(daemon=True)
@app.route("/oauth/callback") def start_scheduler_once():
def oauth_callback(): """Ensures only ONE Gunicorn worker process runs the cron scheduler."""
"""OAuth redirect endpoint that receives the authorization code.""" lock_file_path = "/tmp/scheduler.lock"
code = request.args.get("code")
if not code: # Open or create a lock file
return jsonify({"error": "Missing code parameter"}), 400 lock_file = open(lock_file_path, "wb")
try: try:
webex_manager.exchange_code(code) # Request a non-blocking exclusive lock
return jsonify({"status": "success", "message": "Webex tokens stored successfully in MongoDB!"}), 200 fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
except Exception as e:
current_app.logger.exception("Failed to exchange OAuth code") # Register job schedules
return jsonify({"error": f"OAuth exchange failed: {str(e)}"}), 500 scheduler.add_job(
func=job_server_cve_audit,
args=[shodan_auditor, db],
trigger="cron",
hour=0,
minute=0,
id="server_cve_audit_job",
replace_existing=True
)
scheduler.add_job(
func=job_client_ip_audit,
args=[shodan_auditor, db],
trigger="cron",
hour="*/6",
minute=15,
id="client_ip_audit_job",
replace_existing=True
)
scheduler.add_job(
func=job_request_telemetry,
args=[send_command],
trigger="cron",
hour="1,13",
minute=0,
id="request_telemetry_job",
replace_existing=True
)
scheduler.start()
print(f"[Cron] Scheduler started successfully in Worker PID {os.getpid()}")
# Ensure lock release on shutdown
def cleanup():
try:
fcntl.flock(lock_file, fcntl.LOCK_UN)
lock_file.close()
except Exception:
pass
atexit.register(cleanup)
except (IOError, OSError):
# Lock acquired by another worker - skip starting scheduler
print(f"[Cron] Worker PID {os.getpid()} skipped scheduler (already running in another worker).")
# Initialize single-instance scheduler
start_scheduler_once()
# ---------------------------------------------------------
# Authentication Middleware
# ---------------------------------------------------------
EXEMPT_ROUTES = {'hello_world', 'oauth_callback', 'odata_metadata', 'debug_telemetryrequest', 'trigger_job_manually', 'debug_unsafe_dish', 'debug_safe_dish'}
@app.before_request
def authenticate_request():
if request.endpoint in EXEMPT_ROUTES or request.method == 'OPTIONS':
return
# Extract API Key from Header (X-API-Key or Bearer token)
api_key = request.headers.get("X-API-Key")
if not api_key:
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
api_key = auth_header.split(" ")[1]
if not api_key:
return jsonify({
"@odata.error": {
"code": "401",
"message": "Unauthorized: Missing API Key header"
}
}), 401
# Validate key against 'devices_authentication' collection
device_auth = db.devices_authentication.find_one({"api_key": api_key})
if not device_auth:
return jsonify({
"@odata.error": {
"code": "401",
"message": "Unauthorized: Invalid API Key"
}
}), 401
# Attach device context (works for both Microwaves and Node-RED)
g.device_id = device_auth.get("device_id")
# --------------------------------------------------------- # ---------------------------------------------------------
# Application Routes # Application Routes
@@ -89,243 +196,359 @@ def oauth_callback():
@app.route("/") @app.route("/")
def hello_world(): def hello_world():
gen = generate(prompt="Say Hello, to the user !") gen = generate(prompt="Say Hello, to the user !")
print(gen)
return f"<p>{gen}</p>" return f"<p>{gen}</p>"
@app.route("/oauth/callback")
def oauth_callback():
code = request.args.get("code")
if not code:
return jsonify({"error": "Missing code parameter"}), 400
try:
webex_manager.exchange_code(code)
return jsonify({"status": "success", "message": "Webex tokens stored successfully!"}), 200
except Exception as e:
current_app.logger.exception("Failed to exchange OAuth code")
return jsonify({"error": "OAuth exchange failed"}), 500
@app.route("/cooking-params", methods=["POST"]) @app.route("/cooking-params", methods=["POST"])
async def cooking_params(): async def cooking_params():
try: try:
data = request.get_json() data = request.get_json()
if not data or not isinstance(data, dict):
if not data: return jsonify({"@odata.error": {"code": "400", "message": "Invalid payload"}}), 400
return jsonify({"error": "Invalid or missing JSON payload"}), 400
# data["microwave_id"] = g.device_id
forwarded_for = request.headers.get('X-Forwarded-For')
client_ip = forwarded_for.split(',')[0].strip() if forwarded_for else request.remote_addr
device_ip_collection.insert_one({
"microwave_id": g.device_id,
"client_ip": client_ip,
"received_at": datetime.now(timezone.utc)
})
# Extract user or device parameters
height_cm = float(data.get("dish_height", 4.0)) height_cm = float(data.get("dish_height", 4.0))
initial_temp_c = float(data.get("ir_initial_temp", 20.0)) initial_temp_c = float(data.get("ir_initial_temp", 20.0))
microwave_wattage = int(data.get("microwave_wattage", 900)) microwave_wattage = int(data.get("microwave_wattage", 900))
defrost_mode = bool(data.get("defrost_mode", False)) defrost_mode = bool(data.get("defrost_mode", False))
# 1. Save Camera Image
camera_image_b64 = data.get("camera_image") camera_image_b64 = data.get("camera_image")
if not camera_image_b64: if not camera_image_b64:
return jsonify({"error": "Missing required field 'camera_image'"}), 400 return jsonify({"@odata.error": {"code": "400", "message": "Missing camera_image"}}), 400
filename = f"dish_{uuid.uuid4().hex}.jpg" filename = f"dish_{uuid.uuid4().hex}.jpg"
filepath = os.path.join(CAMERA_IMAGE_DIR, filename) filepath = os.path.join(CAMERA_IMAGE_DIR, filename)
try: with open(filepath, "wb") as f:
with open(filepath, "wb") as f: f.write(base64.b64decode(camera_image_b64))
f.write(base64.b64decode(camera_image_b64)) data["camera_image"] = filepath
data["camera_image"] = filepath
except Exception as e: enhanced_filepath = enhance_image_for_ai(filepath)
current_app.logger.exception("Failed to save camera image")
return jsonify({"error": f"Failed to save camera image: {str(e)}"}), 500
# 2. Run Vision Safety Check & Cook Planner Concurrently # Concurrent AI Execution
try: safety_task = asyncio.to_thread(safety_checker.check_dish_safety, filepath)
if getattr(config, "DEBUG_DANGEROUS_AREA", False): planner_task = asyncio.to_thread(
print(f"[Debug] Running safety check on predefined image") microwave_cook_planner.generate_plan,
safety_task = asyncio.to_thread(safety_checker.check_dish_safety, os.path.join(CAMERA_IMAGE_DIR, "dish_0de0ee1dab8949fc8e78796947e24ed3.jpg")) image_path=enhanced_filepath,
elif getattr(config, "DEBUG", False): height_cm=height_cm,
safety_task = asyncio.to_thread(safety_checker.check_dish_safety, os.path.join("microwaveDish.jpg")) initial_temp_c=initial_temp_c,
else: microwave_wattage=microwave_wattage,
safety_task = asyncio.to_thread(safety_checker.check_dish_safety, filepath) defrost_mode=defrost_mode
planner_task = asyncio.to_thread( )
microwave_cook_planner.generate_plan,
image_path=filepath,
height_cm=height_cm,
initial_temp_c=initial_temp_c,
microwave_wattage=microwave_wattage,
defrost_mode=defrost_mode
)
# Execute both concurrently and await results safety_result, cook_plan = await asyncio.gather(safety_task, planner_task)
safety_result, cook_plan = await asyncio.gather(safety_task, planner_task)
except Exception as e:
current_app.logger.exception("Exception during safety check or cook planning")
return jsonify({"error": f"Task execution failed: {str(e)}"}), 500
# 4. Attach Cooking Plan & safety check and Save to MongoDB
data["safety_check"] = safety_result data["safety_check"] = safety_result
data["analysis_results"] = cook_plan data["analysis_results"] = cook_plan
try: inserted = cooking_collection.insert_one(data)
cooking_collection.insert_one(data) doc_id = str(inserted.inserted_id)
data.pop("_id", None)
except Exception as e: # Base OData v4 response wrapper
return jsonify({"error": f"Database error: {str(e)}"}), 500 odata_response = {
"@odata.context": f"{request.host_url.rstrip('/')}/$metadata#CookingParams/$entity",
"@odata.id": f"{request.host_url.rstrip('/')}/cooking-params('{doc_id}')",
"id": doc_id,
"plan": cook_plan
}
# 3. Evaluate Safety Result
if not safety_result.get("is_safe", True): if not safety_result.get("is_safe", True):
current_app.logger.warning(f"Unsafe dish detected: {safety_result}") odata_response.update({
return jsonify({ "error": "Unsafe cooking area",
"error": "Safety hazard detected in microwave area",
"is_safe": False, "is_safe": False,
"warning_message": safety_result.get("warning_message", "Unsafe materials detected."), "warning_message": safety_result.get("warning_message", "Unsafe materials detected."),
"detected_hazards": safety_result.get("detected_hazards", []) "detected_hazards": safety_result.get("detected_hazards", [])
}), 200 })
return jsonify(odata_response), 200
return jsonify(odata_response), 201
return jsonify(cook_plan), 201
except Exception as e: except Exception as e:
current_app.logger.exception("Exception in /cooking-params") current_app.logger.exception("Error in /cooking-params")
return jsonify({"@odata.error": {"code": "500", "message": "Internal processing error"}}), 500
@app.route("/alert", methods=["POST"]) @app.route("/alert", methods=["POST"])
def alert(): def alert():
data = request.get_json() or {} data = request.get_json()
if data is None or not isinstance(data, dict):
# 1. Inject UTC timestamp return jsonify({
"@odata.error": {
"code": "400",
"message": "Invalid or missing JSON payload"
}
}), 400
# Auto-populate or override orchestrator_id using authenticated device context
orchestrator_id = data.get("orchestrator_id") or getattr(g, "device_id", None)
data["orchestrator_id"] = orchestrator_id
data["created_at"] = datetime.now(timezone.utc) data["created_at"] = datetime.now(timezone.utc)
saved_alert = alert_collection.insert_one(data)
try:
orchestrator_id = data.get("orchestrator_id") saved_alert = alert_collection.insert_one(data)
doc_id = str(saved_alert.inserted_id)
except Exception as e:
current_app.logger.exception("Failed to insert alert into database")
return jsonify({
"@odata.error": {
"code": "500",
"message": "Database write failed"
}
}), 500
alert_info = data.get("alert", {}) alert_info = data.get("alert", {})
alert_type = alert_info.get("type", "Unknown") alert_type = alert_info.get("type", "Unknown")
alert_message = alert_info.get("message", "No message provided") alert_message = alert_info.get("message", "No message provided")
# --- GET CLIENT METADATA ---
client_name = f"Unknown Client (Orchestrator {orchestrator_id})" if orchestrator_id else "Unknown Client"
client_email = None
client_phone = None
if orchestrator_id:
client_doc = client_collection.find_one({
"devices": {
"$elemMatch": {
"device_type": "orchestrator",
"device_id": str(orchestrator_id)
}
}
})
if client_doc:
client_name = client_doc.get("client_name", client_name)
contact_info = client_doc.get("contact_info", {})
client_email = contact_info.get("email")
client_phone = contact_info.get("phone") # Extract phone number for Twilio
# --- WEBEX ROOM LOGIC ---
webex_room_id = None webex_room_id = None
room_title = f"Support - {client_name}"
room_link = None room_link = None
room_title = None
webex_status = "skipped" webex_status = "skipped"
# --- TWILIO SMS LOGIC ---
sms_sent = False sms_sent = False
three_minutes_ago = datetime.now(timezone.utc) - timedelta(minutes=3)
recent_alert = alert_collection.find_one({ if alert_type != "COOKING_SAFETY":
"orchestrator_id": orchestrator_id,
"webex_room_id": {"$exists": True, "$ne": None},
"created_at": {"$gte": three_minutes_ago},
"_id": {"$ne": saved_alert.inserted_id}
}, sort=[("created_at", pymongo.DESCENDING)])
if recent_alert: client_name = f"Unknown Client (Orchestrator {orchestrator_id})" if orchestrator_id else "Unknown Client"
webex_room_id = recent_alert["webex_room_id"] client_email = None
room_title = recent_alert.get("webex_room_title", room_title) client_phone = None
try:
webex_manager.send_alert_info_message( if orchestrator_id:
room_id=webex_room_id, client_doc = client_collection.find_one({
alert_type=alert_type, "devices": {
alert_message=alert_message, "$elemMatch": {
alert_id=str(saved_alert.inserted_id), "device_type": "orchestrator",
added_alert_info=True "device_id": str(orchestrator_id)
) }
webex_status = "reused_room" }
room_link = f"https://web.webex.com/spaces/{webex_room_id}" })
except Exception as e:
current_app.logger.exception("Failed to send follow-up message to existing Webex room")
webex_status = f"message_failed: {str(e)}"
else:
try:
room_details = webex_manager.create_support_room(
client_name,
client_email,
alert_type,
alert_message,
str(saved_alert.inserted_id)
)
if isinstance(room_details, dict): if client_doc:
webex_room_id = room_details.get("id") client_name = client_doc.get("client_name", client_name)
room_title = room_details.get("title", room_title) contact_info = client_doc.get("contact_info", {})
# Set room_link if your webex_manager returns meetingLink or custom URL client_email = contact_info.get("email")
room_link = room_details.get("meetingLink") or f"https://web.webex.com/spaces/{webex_room_id}" client_phone = contact_info.get("phone")
else:
webex_room_id = room_details
room_link = f"https://web.webex.com/spaces/{webex_room_id}"
webex_status = "created_new_room" room_title = f"Support - {client_name}"
except Exception as e: three_minutes_ago = datetime.now(timezone.utc) - timedelta(minutes=3)
current_app.logger.exception("Failed to create Webex support room")
webex_status = f"creation_failed: {str(e)}"
# --- SEND SMS ---
if webex_room_id and client_phone and not SAVEUP_TWILIO_API_TOKEN:
sms_sent = send_alert_sms(
to_phone=client_phone,
room_title=room_title,
room_link=room_link,
client_email=client_email
)
# Update document with room and sms details recent_alert = alert_collection.find_one({
if webex_room_id: "orchestrator_id": orchestrator_id,
alert_collection.update_one( "webex_room_id": {"$exists": True, "$ne": None},
{"_id": saved_alert.inserted_id}, "created_at": {"$gte": three_minutes_ago},
{"$set": {"webex_room_id": webex_room_id, "webex_room_title": room_title, "webex_room_link": room_link, "sms_sent": sms_sent}} "_id": {"$ne": saved_alert.inserted_id}
) }, sort=[("created_at", DESCENDING)])
else: # Just save the sms
alert_collection.update_one(
{"_id": saved_alert.inserted_id},
{"$set": {"sms_sent": sms_sent}}
)
if recent_alert:
webex_room_id = recent_alert["webex_room_id"]
room_title = recent_alert.get("webex_room_title", room_title)
try:
webex_manager.send_alert_info_message(
room_id=webex_room_id,
alert_type=alert_type,
alert_message=alert_message,
alert_id=doc_id,
added_alert_info=True
)
webex_status = "reused_room"
room_link = recent_alert.get("webex_room_link") or f"https://web.webex.com/spaces/{webex_room_id}"
except Exception as e:
current_app.logger.exception("Failed to send follow-up message to Webex room")
webex_status = f"message_failed: {str(e)}"
else:
try:
room_details = webex_manager.create_support_room(
client_name,
client_email,
alert_type,
alert_message,
doc_id
)
if isinstance(room_details, dict):
webex_room_id = room_details.get("id")
room_title = room_details.get("title", room_title)
room_link = room_details.get("meetingLink") or f"https://web.webex.com/spaces/{webex_room_id}"
else:
webex_room_id = room_details
room_link = f"https://web.webex.com/spaces/{webex_room_id}"
webex_status = "created_new_room"
except Exception as e:
current_app.logger.exception("Failed to create Webex support room")
webex_status = f"creation_failed: {str(e)}"
if webex_room_id and client_phone and not SAVEUP_TWILIO_API_TOKEN:
sms_sent = send_alert_sms(
to_phone=client_phone,
room_title=room_title,
room_link=room_link,
client_email=client_email
)
update_fields = {"sms_sent": sms_sent}
if webex_room_id:
update_fields.update({
"webex_room_id": webex_room_id,
"webex_room_title": room_title,
"webex_room_link": room_link
})
alert_collection.update_one({"_id": saved_alert.inserted_id}, {"$set": update_fields})
# OData v4 Created Response Payload
return jsonify({ return jsonify({
"status": "success", "@odata.context": f"{request.host_url.rstrip('/')}/$metadata#Alerts/$entity",
"message": "Alert saved", "@odata.id": f"{request.host_url.rstrip('/')}/alert('{doc_id}')",
"id": doc_id,
"status": "success",
"message": "Alert saved",
"orchestrator_id": orchestrator_id,
"webex_room_id": webex_room_id, "webex_room_id": webex_room_id,
"webex_room_url": room_link, "webex_room_url": room_link,
"webex_status": webex_status, "webex_status": webex_status,
"sms_sent": sms_sent "sms_sent": sms_sent
}), 200 }), 201
@app.route("/telemetry", methods=["POST"]) @app.route("/telemetry", methods=["POST"])
def telemetry(): def telemetry():
data = request.get_json() data = request.get_json()
if data is None: if data is None:
return jsonify({"error": "Invalid or missing JSON payload"}), 400 return jsonify({"@odata.error": {"code": "400", "message": "Invalid JSON payload"}}), 400
# If the payload was double-encoded as a string, deserialize it
if isinstance(data, str): if isinstance(data, str):
try: try:
data = json.loads(data) data = json.loads(data)
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
return jsonify({"error": "String payload could not be parsed as JSON"}), 400 return jsonify({"@odata.error": {"code": "400", "message": "String payload invalid"}}), 400
if not isinstance(data, dict): data["device_id"] = g.device_id
return jsonify({"error": "Expected a JSON object/dictionary"}), 400
# Stamp UTC timestamp for Node-RED queries
data["received_at"] = datetime.now(timezone.utc).isoformat() data["received_at"] = datetime.now(timezone.utc).isoformat()
try: try:
telemetry_collection.insert_one(data) inserted = telemetry_collection.insert_one(data)
return jsonify({"status": "success", "message": "Telemetry saved"}), 200 doc_id = str(inserted.inserted_id)
except Exception as e:
return jsonify({"error": f"Database error: {str(e)}"}), 500
# OData v4 Created Response Envelope
return jsonify({
"@odata.context": f"{request.host_url.rstrip('/')}/$metadata#Telemetry/$entity",
"@odata.id": f"{request.host_url.rstrip('/')}/telemetry('{doc_id}')",
"id": doc_id,
"status": "success",
"message": "Telemetry saved",
"device_id": g.device_id,
"received_at": data["received_at"]
}), 201
except Exception as e:
current_app.logger.exception("Database write failed")
return jsonify({"@odata.error": {"code": "500", "message": "Database write failed"}}), 500
@app.route('/api/security/cve-audit', methods=['POST', 'GET'])
def run_cve_audit():
"""Protected endpoint triggered by Node-RED using REST API + API Key."""
# Only node-red is allowed in this endpoint
if g.device_id != "node-red":
return jsonify({
"@odata.error": {
"code": "403",
"message": "Forbidden: Not allowed to trigger CVE audit"
}
}), 403
audit_results = shodan_auditor.audit_server_vulnerabilities()
inserted = cve_audit_results.insert_one({
"triggered_by": g.device_id,
"audit_results": audit_results,
"timestamp": datetime.now(timezone.utc)
})
doc_id = str(inserted.inserted_id)
status_code = 200 if audit_results.get("status") == "PASS" else 409
return jsonify({
"@odata.context": f"{request.host_url.rstrip('/')}/$metadata#CveAudit/$entity",
"@odata.id": f"{request.host_url.rstrip('/')}/api/security/cve-audit('{doc_id}')",
"id": doc_id,
"status": audit_results.get("status"),
"audit_results": audit_results,
"timestamp": datetime.now(timezone.utc).isoformat()
}), status_code
@app.route("/rfid-card", methods=["GET"])
def get_rfid_cards():
"""OData compliant endpoint for technician RFID card validation."""
filter_str = request.args.get("$filter", "")
card_id = request.args.get("card_id")
mongo_query = {}
# Extract card_id from OData $filter string if not supplied directly
if not card_id and filter_str:
match = re.search(r"(?:card_id|cardId)\s+eq\s+['\"]([^'\"]+)['\"]", filter_str, re.IGNORECASE)
if match:
card_id = match.group(1)
else:
# Generic fallback to extract quoted strings
generic_match = re.search(r"['\"]([a-zA-Z0-9]+)['\"]", filter_str)
if generic_match:
card_id = generic_match.group(1)
if card_id:
mongo_query["card_id"] = card_id
# Filter for active/valid cards only
mongo_query["valid"] = True
try:
cards = list(rfid_cards_collection.find(mongo_query))
result_value = [
{
"id": str(card["_id"]),
"card_id": card.get("card_id"),
"valid": card.get("valid", False)
}
for card in cards
]
return jsonify({
"@odata.context": f"{request.host_url.rstrip('/')}/$metadata#RfidCards",
"value": result_value
}), 200
except Exception as e:
current_app.logger.exception("Error querying rfid_cards collection")
return jsonify({"@odata.error": {"code": "500", "message": "Database query error"}}), 500
# ---------------------------------------------------------
# Debug Endpoints (Protected)
# ---------------------------------------------------------
@app.route("/debug/telemetryrequest", methods=["GET"]) @app.route("/debug/telemetryrequest", methods=["GET"])
def debug_telemetryrequest(): def debug_telemetryrequest():
@@ -345,11 +568,100 @@ def debug_telemetryrequest():
}), 200 }), 200
except Exception as e: except Exception as e:
return jsonify({"error": f"Failed to publish MQTT command: {str(e)}"}), 500 return jsonify({"error": f"Failed to publish MQTT command: {str(e)}"}), 500
@app.route('/debug/run-job/<job_id>', methods=['POST', 'GET'])
def trigger_job_manually(job_id):
"""Manually triggers any scheduled job immediately by its ID."""
job = scheduler.get_job(job_id)
if not job:
return jsonify({
"error": f"Job '{job_id}' not found",
"available_jobs": [j.id for j in scheduler.get_jobs()]
}), 404
try:
job.func(*job.args)
return jsonify({
"status": "success",
"message": f"Job '{job_id}' executed successfully."
}), 200
except Exception as e:
return jsonify({
"status": "error",
"message": f"Job execution failed: {str(e)}"
}), 500
@app.route("/debug/dishsafety/not_safe", methods=["GET"])
def debug_unsafe_dish():
"""Debug endpoint to simulate an unsafe dish scenario."""
# Simulated unsafe dish data
unsafe_dish = "storage/dishPhotos/dish_78827473286c4f67855f3fbd0ccb9bb4.jpg"
@app.route("/debug", methods=["GET"]) # Call the cooking_params endpoint logic directly
def debug(): return safety_checker.check_dish_safety(unsafe_dish)
return safety_checker.check_dish_safety(os.path.join(CAMERA_IMAGE_DIR, "dish_0de0ee1dab8949fc8e78796947e24ed3.jpg"))
@app.route("/debug/dishsafety/safe", methods=["GET"])
def debug_safe_dish():
"""Debug endpoint to simulate a safe dish scenario."""
# Simulated safe dish data
unsafe_dish = "storage/dishPhotos/dish_af60c26a08a74ce894d453394f417c22.jpg"
# Call the cooking_params endpoint logic directly
return safety_checker.check_dish_safety(unsafe_dish)
# ---------------------------------------------------------
# OData Metadata Definition
# ---------------------------------------------------------
@app.route("/$metadata", methods=["GET"])
def odata_metadata():
"""Provides mandatory OData schema definition for validation."""
xml_metadata = """<?xml version="1.0" encoding="utf-8"?>
<edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx">
<edmx:DataServices>
<Schema Namespace="MicrowaveNetwork" xmlns="http://docs.oasis-open.org/odata/ns/edm">
<EntityType Name="Telemetry">
<Key><PropertyRef Name="id"/></Key>
<Property Name="id" Type="Edm.String" Nullable="false"/>
<Property Name="device_id" Type="Edm.String"/>
<Property Name="received_at" Type="Edm.DateTimeOffset"/>
</EntityType>
<EntityType Name="CookingParams">
<Key><PropertyRef Name="id"/></Key>
<Property Name="id" Type="Edm.String" Nullable="false"/>
<Property Name="microwave_id" Type="Edm.String"/>
</EntityType>
<EntityType Name="CveAudit">
<Key><PropertyRef Name="id"/></Key>
<Property Name="id" Type="Edm.String" Nullable="false"/>
<Property Name="status" Type="Edm.String"/>
</EntityType>
<EntityType Name="Alert">
<Key><PropertyRef Name="id"/></Key>
<Property Name="id" Type="Edm.String" Nullable="false"/>
<Property Name="orchestrator_id" Type="Edm.String"/>
<Property Name="webex_room_id" Type="Edm.String"/>
<Property Name="webex_status" Type="Edm.String"/>
<Property Name="sms_sent" Type="Edm.Boolean"/>
</EntityType>
<EntityType Name="RfidCard">
<Key><PropertyRef Name="id"/></Key>
<Property Name="id" Type="Edm.String" Nullable="false"/>
<Property Name="card_id" Type="Edm.String" Nullable="false"/>
<Property Name="valid" Type="Edm.Boolean" Nullable="false"/>
</EntityType>
<EntityContainer Name="Container">
<EntitySet Name="Telemetry" EntityType="MicrowaveNetwork.Telemetry"/>
<EntitySet Name="CookingParams" EntityType="MicrowaveNetwork.CookingParams"/>
<EntitySet Name="CveAudit" EntityType="MicrowaveNetwork.CveAudit"/>
<EntitySet Name="Alerts" EntityType="MicrowaveNetwork.Alert"/>
<EntitySet Name="RfidCards" EntityType="MicrowaveNetwork.RfidCard"/>
</EntityContainer>
</Schema>
</edmx:DataServices>
</edmx:Edmx>"""
return Response(xml_metadata, mimetype="application/xml")
if __name__ == "__main__": if __name__ == "__main__":
app.run(debug=getattr(config, "DEBUG", True)) app.run(debug=getattr(config, "DEBUG", False))
+26
View File
@@ -0,0 +1,26 @@
import cv2
def enhance_image_for_ai(input_path: str) -> str:
"""Smooths camera sensor noise and brightens dark areas for vision LLMs."""
img = cv2.imread(input_path)
if img is None:
return input_path
# 1. Remove ISO sensor noise (grain)
denoised = cv2.fastNlMeansDenoisingColored(img, None, h=7, hColor=7, templateWindowSize=7, searchWindowSize=21)
# 2. Convert to LAB color space for luminance enhancement
lab = cv2.cvtColor(denoised, cv2.COLOR_BGR2LAB)
l, a, b = cv2.split(lab)
# 3. Apply softer CLAHE (lower clipLimit prevents noise amplification)
clahe = cv2.createCLAHE(clipLimit=1.8, tileGridSize=(8, 8))
cl = clahe.apply(l)
# 4. Merge channels back
limg = cv2.merge((cl, a, b))
enhanced = cv2.cvtColor(limg, cv2.COLOR_LAB2BGR)
enhanced_path = input_path.replace(".jpg", "_enhanced.jpg")
cv2.imwrite(enhanced_path, enhanced, [cv2.IMWRITE_JPEG_QUALITY, 90])
return enhanced_path
+84
View File
@@ -0,0 +1,84 @@
import os
import time
from datetime import datetime, timezone, timedelta
# Fallback base URL since 'request.host_url' is not available in background threads
SERVER_BASE_URL = os.getenv("SERVER_BASE_URL", "https://smartwave.matthiasg.dev")
def job_server_cve_audit(shodan_auditor, db):
"""Job 1: Audits Flask server public IP for CVEs and saves to MongoDB."""
print("[Cron] Starting Server CVE Audit...")
audit_results = shodan_auditor.audit_server_vulnerabilities()
db.cve_audit_results.insert_one({
"audit_results": audit_results,
"timestamp": datetime.now(timezone.utc)
})
print(f"[Cron] Server CVE Audit finished. Status: {audit_results.get('status')}")
def job_client_ip_audit(shodan_auditor, db):
"""
Job 2: Fetches distinct client IPs logged in the last 24h and audits them.
Deduplicates IPs and applies rate limits to protect Shodan API credits.
"""
print("[Cron] Starting Client IP Security Audit...")
pipeline = [
{"$group": {
"_id": "$client_ip",
"microwave_ids": {"$addToSet": "$microwave_id"},
"last_seen": {"$max": "$received_at"}
}}
]
recent_clients = list(db.device_ips.aggregate(pipeline))
for entry in recent_clients:
client_ip = entry["_id"]
# Skip local/private IPs
if not client_ip or client_ip in ["127.0.0.1", "localhost"] or client_ip.startswith(("10.", "192.168.", "172.16.")):
continue
profile = shodan_auditor.profile_client_ip(client_ip)
# Upsert client profile in a separate collection
db.client_audit_results.update_one(
{"client_ip": client_ip},
{
"$set": {
"client_ip": client_ip,
"microwave_ids": entry["microwave_ids"],
"last_seen": entry["last_seen"],
"profile": profile,
"audited_at": datetime.now(timezone.utc)
}
},
upsert=True
)
# Pause 1 second to respect Shodan API rate limits (1 req/sec)
time.sleep(1)
print(f"[Cron] Client IP Security Audit completed for {len(recent_clients)} IPs.")
def job_request_telemetry(send_command_func):
"""Job 3: Triggers MQTT command to request telemetry from all microwaves."""
print("[Cron] Sending MQTT Telemetry Request to cmd/all...")
telemetry_url = f"{SERVER_BASE_URL.rstrip('/')}/telemetry"
cmd_payload = {
"action": "request_telemetry",
"endpoint": telemetry_url
}
try:
send_command_func(topic="cmd/all", payload=cmd_payload)
print("[Cron] Telemetry request successfully published.")
except Exception as e:
print(f"[Cron Error] Failed to publish MQTT command: {str(e)}")
+18 -2
View File
@@ -1,6 +1,12 @@
import cv2 import cv2
import numpy as np import numpy as np
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
import sys
sys.path.insert(0, '..')
try:
from shared import config
except ImportError:
from ..shared import config
class MicrowaveDishAnalyzer: class MicrowaveDishAnalyzer:
@@ -10,8 +16,7 @@ class MicrowaveDishAnalyzer:
""" """
# Constant scale ratio: Centimeters per Pixel. # Constant scale ratio: Centimeters per Pixel.
# TODO : Replace this value once your camera calibration is complete. CM_PER_PIXEL: float = 0.005925926
CM_PER_PIXEL: float = 0.05 # Example: 1 pixel = 0.05 cm
def __init__(self, cm_per_pixel: Optional[float] = None): def __init__(self, cm_per_pixel: Optional[float] = None):
if cm_per_pixel is not None: if cm_per_pixel is not None:
@@ -43,6 +48,17 @@ class MicrowaveDishAnalyzer:
largest_contour = max(contours, key=cv2.contourArea) largest_contour = max(contours, key=cv2.contourArea)
area_pixels = cv2.contourArea(largest_contour) area_pixels = cv2.contourArea(largest_contour)
# DEBUG CONDITION: Draw and save contour image if DEBUG_DISHANALYZER is set
if getattr(config, "DEBUG_DISHANALYZER", False):
annotated_image = image.copy()
# Draw the contour in red (BGR: 0, 0, 255) with thickness of 2
cv2.drawContours(annotated_image, [largest_contour], -1, (0, 0, 255), 2)
# Handle file extensions gracefully
analyzed_path = image_path.replace(".jpg", "_analyzed.jpg")
cv2.imwrite(analyzed_path, annotated_image)
# 5. Convert pixels² to cm² using scale ratio squared # 5. Convert pixels² to cm² using scale ratio squared
area_cm2 = area_pixels * (self.cm_per_pixel ** 2) area_cm2 = area_pixels * (self.cm_per_pixel ** 2)
return float(area_cm2) return float(area_cm2)
+41
View File
@@ -0,0 +1,41 @@
def parse_odata_query(request_args):
"""
Translates standard OData query parameters ($top, $skip, $orderby, $filter)
into MongoDB query parameters.
"""
limit = int(request_args.get('$top', 50))
skip = int(request_args.get('$skip', 0))
# Parse $orderby (e.g., $orderby=received_at desc)
sort = []
orderby_str = request_args.get('$orderby')
if orderby_str:
parts = orderby_str.split()
field = parts[0]
direction = -1 if len(parts) > 1 and parts[1].lower() == 'desc' else 1
sort.append((field, direction))
# Parse basic $filter (e.g., $filter=device_id eq '1')
mongo_filter = {}
filter_str = request_args.get('$filter')
if filter_str and ' eq ' in filter_str:
field, val = filter_str.split(' eq ')
field = field.strip()
val = val.strip().strip("'").strip('"')
# Cast data types
if val.isdigit():
val = int(val)
elif val.lower() == 'true':
val = True
elif val.lower() == 'false':
val = False
mongo_filter[field] = val
return {
"filter": mongo_filter,
"limit": limit,
"skip": skip,
"sort": sort or [("_id", -1)]
}
+2 -1
View File
@@ -6,4 +6,5 @@ requests==2.32.3
paho-mqtt>=1.6,<3 paho-mqtt>=1.6,<3
openai>=1.0.0 openai>=1.0.0
pydantic>=2.0.0 pydantic>=2.0.0
twilio>=9.0.0 twilio>=9.0.0
APScheduler>=3.10.4,<4.0.0
+15 -20
View File
@@ -3,34 +3,27 @@ from pydantic import BaseModel, Field
from APIs.aichat import generate from APIs.aichat import generate
import shared.config as config import shared.config as config
class DishSafetyResult(BaseModel): class DishSafetyResult(BaseModel):
visible_objects: list[str] = Field( utensil_or_foreign_object_detected: bool = Field(
description="List physical container items and utensils visible in the image." description="Set to True if ANY spoon, fork, knife, handle, or foil is inside or touching the bowl. Set to False ONLY if the bowl contains ONLY 100% food."
)
material_analysis: str = Field(
description="Brief description of container material (e.g., standard plastic tray, glass bowl)."
)
detected_hazards: list[str] = Field(
description="List of visually confirmed metal or foil hazards. MUST be empty [] if no metal or foil is present."
) )
is_safe: bool = Field( is_safe: bool = Field(
description="Set to True if detected_hazards is empty. Set to False ONLY if metal or aluminum foil is present." description="MUST be False if utensil_or_foreign_object_detected is True. Set to True ONLY if pure food/bowl with ZERO utensils."
)
detected_hazards: list[str] = Field(
description="List any items found (e.g., ['spoon']). Empty list [] if safe."
) )
warning_message: str = Field( warning_message: str = Field(
description="Short warning if is_safe is False, otherwise an empty string." description="Warning statement if unsafe, otherwise empty string ''."
) )
def check_dish_safety(image_path: str) -> dict: def check_dish_safety(image_path: str) -> dict:
prompt = ( prompt = (
"Analyze this food image for microwave safety.\n\n" "Inspect this top-down photo of a food dish inside a microwave.\n\n"
"DEFAULT ASSUMPTION:\n" "STRICT MICROWAVE RULES:\n"
"- Assume the dish is SAFE (is_safe = True, detected_hazards = []).\n" "1. Check if ANY utensil (spoon, fork, knife, or handle) is present in or on the bowl.\n"
"- Standard food (meat, vegetables, potatoes) and standard containers (black plastic meal trays, plastic bowls, ceramic, glass) are 100% SAFE for microwaves.\n\n" "2. If ANY spoon or utensil is present (metal, plastic, or ceramic), you MUST set `utensil_or_foreign_object_detected = True` and `is_safe = False`.\n"
"STRICT HAZARD RULE:\n" "3. `is_safe` can ONLY be True if the dish contains strictly food and nothing else."
"- Flag as UNSAFE ONLY if you can literally see actual metal cutlery (metal spoon/fork/knife), aluminum foil packaging, or metal foil trim in the image.\n"
"- Do NOT invent or hallucinate hazards. If you do not see shiny metal or aluminum foil, detected_hazards MUST be an empty list []."
) )
response_raw = generate( response_raw = generate(
@@ -39,7 +32,9 @@ def check_dish_safety(image_path: str) -> dict:
output_format=DishSafetyResult, output_format=DishSafetyResult,
should_think=False, should_think=False,
) )
print(f"[Debug] Raw response from AI generator: {response_raw}")
if config.DEBUG_MESSAGES:
print(f"[Debug] Raw response from AI generator: {response_raw}")
if isinstance(response_raw, str): if isinstance(response_raw, str):
try: try:
+2 -2
View File
@@ -2,8 +2,8 @@
# Configuration des ports (À remplacer par tes propres chemins by-path) # Configuration des ports (À remplacer par tes propres chemins by-path)
# Pour trouver tes chemins : ls -l /dev/serial/by-path/ # Pour trouver tes chemins : ls -l /dev/serial/by-path/
PORT_ESP_WIFI="/dev/serial/by-path/pci-0000:00:14.0-usbv2-0:6.1:1.0-port0" PORT_ESP_WIFI="/dev/serial/by-path/pci-0000:00:14.0-usb-0:2.1:1.0-port0"
PORT_ESP_LORA="/dev/serial/by-path/pci-0000:00:14.0-usbv2-0:6.2:1.0-port0" PORT_ESP_LORA="/dev/serial/by-path/pci-0000:00:14.0-usb-0:2.2:1.0-port0"
# Ajoute les autres si besoin... # Ajoute les autres si besoin...
# Configuration Raspberry Pi # Configuration Raspberry Pi
+17 -1
View File
@@ -1,3 +1,19 @@
`esphome run epaper_thread_node.yaml` `esphome run epaper_thread_node.yaml`
`esphome logs epaper_thread_node.yaml` `esphome logs epaper_thread_node.yaml`
# Config OTBR
## 1. Stop the active Thread interface
docker exec -it otbr ot-ctl thread stop
docker exec -it otbr ot-ctl ifconfig down
## 2. Inject your ESP32-H2 Active Dataset TLV
docker exec -it otbr ot-ctl dataset set active 00030000184a0300000b35060004001fffe00208f7754c0c4a4dea9a0708fd1adbbe7435e95405103f543281275a135e38982974aa71e987030f4f70656e5468726561642d3766343101027f410410a500c86abfe7ef17888c2232feac2dc60c0402a0f7f80e080000000000010000
## 3. Commit the dataset as active
docker exec -it otbr ot-ctl dataset commit active
## 4. Bring up the interface and start Thread
docker exec -it otbr ot-ctl ifconfig up
docker exec -it otbr ot-ctl thread start
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -80,7 +80,7 @@ class MicrowaveScreen:
# self.lastUpdateTime = time.time() # Not Used right now # self.lastUpdateTime = time.time() # Not Used right now
if (not microwave_state or microwave_state == MicrowaveState.DONE) and (not cooking_state or cooking_state.state == CookingStates.IDLE): # Is not currently cooking if (not microwave_state or microwave_state in (MicrowaveState.DONE, MicrowaveState.IDLE)) and (not cooking_state or cooking_state.state == CookingStates.IDLE): # Is not currently cooking
self.h_centered_text("Ready !", 28, 1) self.h_centered_text("Ready !", 28, 1)
else: else:
# Screen center # Screen center
+22 -5
View File
@@ -49,19 +49,26 @@ PING_PAYLOAD = {
"type": deviceTypes.DEVICE_TYPES["MICROWAVE"] "type": deviceTypes.DEVICE_TYPES["MICROWAVE"]
} }
last_cooking_update = {}
def send_cooking_update(state=None, start_time=None, estimated_remaining_time=None, paused=None): def send_cooking_update(state=None, start_time=None, estimated_remaining_time=None, paused=None):
"""Sends a flexible COOKING_UPDATE payload containing only populated fields.""" """Sends a flexible COOKING_UPDATE payload containing only populated fields."""
global last_cooking_update
if not lora: if not lora:
return return
send_reliably = False
payload = { payload = {
"id": DEVICE_ID, "id": DEVICE_ID,
"action": LoraCommands.COOKING_UPDATE "action": LoraCommands.COOKING_UPDATE
} }
if state is not None: if state is not None and state != last_cooking_update.get("cooking_state"):
payload["cooking_state"] = state payload["cooking_state"] = state
send_reliably = True
if estimated_remaining_time is not None: if estimated_remaining_time is not None:
payload["estimated_remaining_time"] = estimated_remaining_time payload["estimated_remaining_time"] = estimated_remaining_time
if paused is not None: if paused is not None:
@@ -70,8 +77,11 @@ def send_cooking_update(state=None, start_time=None, estimated_remaining_time=No
# Only transmit if at least one field beyond id and action was provided # Only transmit if at least one field beyond id and action was provided
if len(payload) > 2: if len(payload) > 2:
log(f"[LoRa] Sending COOKING_UPDATE payload: {payload}") log(f"[LoRa] Sending COOKING_UPDATE payload: {payload}")
last_cooking_update = payload.copy()
lora.send(payload) if send_reliably:
lora.send_reliable(payload)
else:
lora.send(payload)
def on_new_alert(alert: Alert): def on_new_alert(alert: Alert):
@@ -195,7 +205,7 @@ def cooking_state_temperature_provider():
return (0.0, 0.0) return (0.0, 0.0)
def cooking_state_on_state_change(state): def cooking_state_on_state_change(state):
global cooking_start_time global cooking_start_time, uart_device
print(f"[CookingState] State changed to: {state.state}") print(f"[CookingState] State changed to: {state.state}")
if state.paused or state.state in (cookingState.CookingStates.DONE, cookingState.CookingStates.IDLE, cookingState.CookingStates.ALERT): if state.paused or state.state in (cookingState.CookingStates.DONE, cookingState.CookingStates.IDLE, cookingState.CookingStates.ALERT):
@@ -204,7 +214,7 @@ def cooking_state_on_state_change(state):
magnetron_led.on() magnetron_led.on()
# Send state updates to WiFi board and Orchestrator # Send state updates to WiFi board and Orchestrator
if uart_device: if uart_device != None:
uart_device.send_as_command(UARTCommand(UARTCommandType.COOKING_STATE_UPDATE, {"state": state.state})) uart_device.send_as_command(UARTCommand(UARTCommandType.COOKING_STATE_UPDATE, {"state": state.state}))
# Send LoRa update with start time and remaining time if beginning cooking process # Send LoRa update with start time and remaining time if beginning cooking process
@@ -336,6 +346,9 @@ async def lora_process_task():
if alert: if alert:
alert.timestamp = data.get("timestamp", time.time()) alert.timestamp = data.get("timestamp", time.time())
alert_manager.add_alert(alert) alert_manager.add_alert(alert)
microwave_state = MicrowaveState.ALERT
if cooking_state is not None:
cooking_state.set_state(cookingState.CookingStates.ALERT)
elif action == LoraCommands.MICROVAVE_STATE_UPDATE: elif action == LoraCommands.MICROVAVE_STATE_UPDATE:
new_state = data.get("new_microwave_state") new_state = data.get("new_microwave_state")
@@ -344,6 +357,10 @@ async def lora_process_task():
microwave_state = new_state microwave_state = new_state
if new_state not in (MicrowaveState.ALERT): # Not alert so we can show the error if new_state not in (MicrowaveState.ALERT): # Not alert so we can show the error
update_screen() update_screen()
if new_state == MicrowaveState.IDLE:
if cooking_state is not None:
cooking_state.set_state(cookingState.CookingStates.IDLE)
await asyncio.sleep_ms(20)
await asyncio.sleep_ms(100) await asyncio.sleep_ms(100)
+8 -1
View File
@@ -2,4 +2,11 @@
# IP Adresses # IP Adresses
- AP (dynamic) : `10.110.0.190` - AP (dynamic) : `10.110.0.190`
- Generated Wifi : `192.168.50.1` - Generated Wifi : `192.168.50.1`
# RFID
## Cards
- 2700423026
- 270042302B
+2 -2
View File
@@ -3,13 +3,13 @@ services:
image: openthread/border-router:latest image: openthread/border-router:latest
container_name: otbr container_name: otbr
restart: unless-stopped restart: unless-stopped
privileged: true # privileged: true
network_mode: host network_mode: host
cap_add: cap_add:
- NET_ADMIN - NET_ADMIN
environment: environment:
- OT_RCP_DEVICE=spinel+hdlc+uart:///dev/ttyUSB1?uart-baudrate=460800 - OT_RCP_DEVICE=spinel+hdlc+uart:///dev/ttyUSB1?uart-baudrate=460800
- OT_INFRA_IF=wlx6815790f3204 - OT_INFRA_IF=wlan1
- OT_THREAD_IF=wpan0 - OT_THREAD_IF=wpan0
- OT_LOG_LEVEL=6 - OT_LOG_LEVEL=6
- FIREWALL=0 - FIREWALL=0
+1
View File
@@ -0,0 +1 @@
zeJlYcaW0dji4A88Q3OGfvqgqXUuUAm0
+17 -12
View File
@@ -3,6 +3,7 @@ set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
REPO_ROOT=$(dirname -- "$SCRIPT_DIR") REPO_ROOT=$(dirname -- "$SCRIPT_DIR")
VENV_DIR="$SCRIPT_DIR/venv"
PYTHON_BIN="${PYTHON_BIN:-python3}" PYTHON_BIN="${PYTHON_BIN:-python3}"
PYTHON_SCRIPT="${1:-$SCRIPT_DIR/main.py}" PYTHON_SCRIPT="${1:-$SCRIPT_DIR/main.py}"
REQUIREMENTS_FILE="$SCRIPT_DIR/requirements.txt" REQUIREMENTS_FILE="$SCRIPT_DIR/requirements.txt"
@@ -19,24 +20,28 @@ cd "$SCRIPT_DIR"
# 2. On lance Docker en arrière-plan # 2. On lance Docker en arrière-plan
echo "Démarrage des conteneurs Docker..." echo "Démarrage des conteneurs Docker..."
# docker compose pull
docker compose up -d --remove-orphans docker compose up -d --remove-orphans
# 3. Installation des dépendances (sans '--user' si on est déjà root sous systemd) # 3. Gestion propre de l'environnement virtuel Python (contourne PEP 668)
if [ ! -d "$VENV_DIR" ]; then
echo "Création de l'environnement virtuel Python..."
"$PYTHON_BIN" -m venv "$VENV_DIR"
fi
# Utiliser le pip/python du venv
VENV_PYTHON="$VENV_DIR/bin/python"
VENV_PIP="$VENV_DIR/bin/pip"
if [ -f "$REQUIREMENTS_FILE" ]; then if [ -f "$REQUIREMENTS_FILE" ]; then
echo "Vérification des dépendances Python..." echo "Vérification et installation des dépendances dans le venv..."
if [ "$(id -u)" -eq 0 ]; then "$VENV_PIP" install --upgrade pip >/dev/null 2>&1 || true
"$PYTHON_BIN" -m pip install -r "$REQUIREMENTS_FILE" "$VENV_PIP" install -r "$REQUIREMENTS_FILE"
else
"$PYTHON_BIN" -m pip install --user -r "$REQUIREMENTS_FILE"
fi
fi fi
# 4. Sécurité anti-conflit : On vérifie si Docker n'a pas verrouillé le port USB de la LA66 # 4. Sécurité anti-conflit : On vérifie si Docker n'a pas verrouillé le port USB de la LA66
PORT="/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0" PORT="/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0"
if [ -e "$PORT" ]; then if [ -e "$PORT" ]; then
echo "Vérification de la disponibilité du port série..." echo "Vérification de la disponibilité du port série..."
# Si le port est occupé, on force sa libération en tuant le processus docker/screen persistant
if lsof "$PORT" >/dev/null 2>&1; then if lsof "$PORT" >/dev/null 2>&1; then
echo "Le port série est occupé ! Libération en cours..." echo "Le port série est occupé ! Libération en cours..."
fuser -k "$PORT" || true fuser -k "$PORT" || true
@@ -44,7 +49,7 @@ if [ -e "$PORT" ]; then
fi fi
fi fi
# 5. Lancement propre du script Python # 5. Lancement propre du script Python via le venv
echo "Launching Python script: $PYTHON_SCRIPT" echo "Launching Python script: $PYTHON_SCRIPT"
cd "$REPO_ROOT" cd "$SCRIPT_DIR"
exec "$PYTHON_BIN" "$PYTHON_SCRIPT" exec "$VENV_PYTHON" "$PYTHON_SCRIPT"
+260 -148
View File
@@ -3,27 +3,32 @@ import json
import time import time
import asyncio import asyncio
import requests import requests
import os
from shared.microwave_state import MicrowaveState, MicrowaveStateFields from shared.microwave_state import MicrowaveState, MicrowaveStateFields
from orchestrateur.sensors import gps
from shared import get_lora, get_mqtt_client, deviceTypes, config, payloads, db from shared import get_lora, get_mqtt_client, deviceTypes, config, payloads, db
from shared.logging import log from shared.logging import log
from shared.cookingState import CookingStates from shared.cookingState import CookingStates
from shared.lora_device import LoraCommands from shared.lora_device import LoraCommands
from shared.alerts import AlertManager, Alert, AlertType from shared.alerts import AlertManager, Alert, AlertType
from sensors import ultrasonicRanger, temp_hum, button, camera, buzzer, rgb_lcd from sensors import ultrasonicRanger, temp_hum, button, camera, buzzer, rgb_lcd, gps, rfid_reader
from lib.systemd_logs import get_systemd_logs from lib.systemd_logs import get_systemd_logs
from external_display_manager import DisplayManager from external_display_manager import DisplayManager
from rgb_lcd_manager import RGBLCDManager from rgb_lcd_manager import RGBLCDManager
from web_server import start_technician_web_server
rgb_lcd.setRGB(255, 255, 255, brightness=0.8) rgb_lcd.setRGB(255, 255, 255, brightness=0.8)
rgb_lcd.setText("SmartWave\nOrchestrateur") rgb_lcd.setText("SmartWave\nOrchestrateur")
# --- CONFIGURATION CONSTANTS --- # --- CONFIGURATION CONSTANTS ---
DISPLAY_DEBOUNCE_SECONDS = 3 # Seconds to wait after first change before broadcasting DISPLAY_DEBOUNCE_SECONDS = 3 # Seconds to wait after first change before broadcasting
TECHNICIAN_MODE = False
def is_technician_mode_active() -> bool:
return TECHNICIAN_MODE
# --- DB SETUP --- # --- DB SETUP ---
DB_PATH = "orchestrateur/db.sqlite" BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DB_PATH = os.path.join(BASE_DIR, "db.sqlite")
def init_db(): def init_db():
"""Ensures the connected_components table exists on startup.""" """Ensures the connected_components table exists on startup."""
@@ -72,7 +77,17 @@ def get_device_id():
pass pass
return "RPI_Orchestrateur_Default" return "RPI_Orchestrateur_Default"
def get_api_key():
for path in ["device_api_key.txt", "/home/pi/SmartWave/orchestrateur/device_api_key.txt"]:
try:
with open(path, "r") as f:
return f.read().strip()
except Exception:
pass
return ""
DEVICE_ID = get_device_id() DEVICE_ID = get_device_id()
API_KEY = get_api_key()
# --- DISPLAY QUEUE & NOTIFICATION HELPERS --- # --- DISPLAY QUEUE & NOTIFICATION HELPERS ---
display_update_queue = None display_update_queue = None
@@ -91,10 +106,19 @@ def on_microwave_change(state: MicrowaveState, field: MicrowaveStateFields):
if field != MicrowaveStateFields.STATE: # No external screen or lcd uses this field, so we can skip updates for it if field != MicrowaveStateFields.STATE: # No external screen or lcd uses this field, so we can skip updates for it
notify_display_update() notify_display_update()
if field == MicrowaveStateFields.STATE and state.state in (MicrowaveState.DONE, MicrowaveState.ANALYZING, MicrowaveState.WAITING_FOR_CLOUD, MicrowaveState.ALERT): if field == MicrowaveStateFields.STATE and state.state in (MicrowaveState.IDLE, MicrowaveState.DONE, MicrowaveState.ANALYZING, MicrowaveState.WAITING_FOR_CLOUD):
# Send to LoRa device if state changed # Send to LoRa device if state changed
lora.send_reliable(payloads.lora_microwave_state(state.state), max_retries=5) lora.send_reliable(payloads.lora_microwave_state(state.state), max_retries=5)
if field == MicrowaveStateFields.COOKING_STATE:
update_lcd_microwave_count()
if state.cooking_state in (CookingStates.DONE, CookingStates.STIRRING_REQUIRED, CookingStates.ALERT):
# Trigger buzzer for user attention
beats = 5 if state.cooking_state == CookingStates.ALERT else (1 if state.cooking_state == CookingStates.STIRRING_REQUIRED else 3)
buzzer.buzzer_siren(beats_nb=beats)
elif state.cooking_state == CookingStates.DONE and state.state == MicrowaveState.COOKING:
state.set_state(MicrowaveState.DONE)
print(f"[{state.microwave_id}] Cooking finished. Waiting for user to remove dish.")
def set_cloud_alert(state: bool): def set_cloud_alert(state: bool):
"""Setter for cloud_alert that triggers a display update when changed.""" """Setter for cloud_alert that triggers a display update when changed."""
@@ -135,6 +159,9 @@ mqtt_client.connect()
mqtt_client.subscribe(config.MQTT_TOPIC_SENSOR, qos=config.MQTT_QOS) mqtt_client.subscribe(config.MQTT_TOPIC_SENSOR, qos=config.MQTT_QOS)
mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS) mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS)
# RFID reader
rfid = rfid_reader.RFIDReader(rx_pin=17)
# DHT sensor warmup # DHT sensor warmup
time.sleep(2) # Allow GrovePi MCU and DHT sensor circuits to settle post-service start time.sleep(2) # Allow GrovePi MCU and DHT sensor circuits to settle post-service start
temp_hum.get_temperature_and_humidity() # Dummy read to flush initial NaN state temp_hum.get_temperature_and_humidity() # Dummy read to flush initial NaN state
@@ -164,7 +191,7 @@ if hasattr(cloud_mqtt_client._client, "loop_start"):
print("[MQTT Cloud] Paho background loop started.") print("[MQTT Cloud] Paho background loop started.")
# --- BACKGROUND TASKS (PRODUCERS) --- # --- BACKGROUND TASKS ---
async def lora_listener_task(): async def lora_listener_task():
"""Polls LoRa and pushes to the async queue.""" """Polls LoRa and pushes to the async queue."""
print("[LoRa] Async listener started.") print("[LoRa] Async listener started.")
@@ -220,32 +247,47 @@ async def cloud_mqtt_listener_task():
}) })
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
# === LCD DISPLAY === async def rfid_listener_task():
def on_screens_change(screens, is_removed: bool, modified_name: str): """Polls RFID reader, verifies cards via OData API, and toggles TECHNICIAN_MODE."""
"""Callback for when screens are added or removed.""" global TECHNICIAN_MODE
log(f"[DisplayManager] Screens changed. Current screens: {list(screens.keys())}") print("[RFID] Async task started on GPIO 17.")
rgb_lcd_manager.set_external_screen_count(len(screens))
# Add Or remove from database of connected components last_scanned_tag = None
# 1. Extract id from name (assuming format "smartwave-epaper-<id>._displaytcp._tcp.local") last_scan_time = 0
display_id = modified_name.split("-")[-1].split(".")[0]
if is_removed:
remove_connected_component(display_id)
else:
save_connected_component(display_id, "external_display")
display_manager.set_on_screens_change_callback(on_screens_change)
def update_lcd_microwave_count(): while True:
"""Updates the LCD with the current number of connected microwaves.""" try:
global microwave_states # Quick non-blocking read via pigpio IPC
count = len(microwave_states) tag_id = await asyncio.to_thread(rfid.read_tag)
rgb_lcd_manager.set_microwave_count(count) current_time = time.time()
def update_lcd_cloud_alert(): if tag_id and tag_id != "0000000000":
"""Updates the LCD with the current cloud connectivity status.""" # Debounce same tag within 3 seconds to avoid spamming the API
global cloud_alert if tag_id == last_scanned_tag and (current_time - last_scan_time) < 3.0:
rgb_lcd_manager.set_cloud_alert(cloud_alert) await asyncio.sleep(0.1)
continue
last_scanned_tag = tag_id
last_scan_time = current_time
print(f"[RFID] Tag detected: {tag_id}. Verifying with API...")
is_valid = await check_rfid_card_api(tag_id)
if is_valid:
TECHNICIAN_MODE = True
print(f"[RFID] Card {tag_id} VALIDATED! -> TECHNICIAN_MODE = TRUE")
# Optional: Sound positive feedback beep
buzzer.buzzer_siren(beats_nb=2)
else:
print(f"[RFID] Card {tag_id} REJECTED / Invalid.")
# Optional: Sound error feedback beep
buzzer.buzzer_siren(beats_nb=4)
except Exception as e:
print(f"[RFID Task] Error reading RFID tag: {e}")
# Sleep 100ms between checks to keep CPU usage near zero
await asyncio.sleep(0.2)
async def display_broadcast_worker_task(): async def display_broadcast_worker_task():
""" """
@@ -278,7 +320,123 @@ async def display_broadcast_worker_task():
microwave_states=microwave_states, microwave_states=microwave_states,
cloud_alert=cloud_alert cloud_alert=cloud_alert
) )
async def process_messages_task():
"""Consumes the unified event queue."""
# Helper to normalize config topics to str
def to_str(val):
return val.decode('utf-8') if isinstance(val, bytes) else val
while True:
msg = await async_event_queue.get()
source = msg["source"]
data = msg["data"]
if source == "LoRa":
if "action" in data.get("data", {}):
if type(data["data"]) != dict:
print(f"[LoRa] Invalid data format received: {data['data']}")
continue
action = data["data"]["action"]
if action == LoraCommands.COOKING_UPDATE:
mw_id = data["data"].get("id")
if mw_id in microwave_states:
state_info = data["data"]
microwave_states[mw_id].set_cooking_state(state_info.get("cooking_state", microwave_states[mw_id].cooking_state))
microwave_states[mw_id].set_cooking_estimated_remaining_time(state_info.get("estimated_remaining_time", microwave_states[mw_id].cooking_estimated_remaining_time))
microwave_states[mw_id].set_paused(state_info.get("paused", microwave_states[mw_id].paused))
elif action == LoraCommands.NEW_ALERT:
alert_type = data["data"].get("alert_type")
alert_message = data["data"].get("alert_message")
alert = Alert(alert_type, alert_message, redistributed=True)
alert_manager.add_alert(alert)
print(f"[LoRa] New alert received: {alert.to_dict()}")
else:
print(f"[LoRa] Unhandled action received: {action} with data: {data['data']}")
elif source == "MQTT":
topic = msg["topic"]
hello_topic = to_str(config.MQTT_TOPIC_HELLO)
sensor_topic = to_str(config.MQTT_TOPIC_SENSOR)
if topic == hello_topic:
if data.get("id_orchestrator") != DEVICE_ID:
component_id = data.get("id_microwave")
component_type = data.get("type", deviceTypes.DEVICE_TYPES["MICROWAVE"])
if component_id:
print(f"[MQTT] Hello received from '{component_id}' ({component_type}). Updating DB & sending ACK.")
# Offload DB insertion to async thread execution pool
await asyncio.to_thread(save_connected_component, component_id, component_type)
# Pass change callback to newly connected microwave
microwave_states[component_id] = MicrowaveState(
component_id,
on_change_callback=on_microwave_change
)
notify_display_update() # Update the external screens
update_lcd_microwave_count() # Update the LCD with new microwave count
mqtt_client.publish(
config.MQTT_TOPIC_HELLO,
payloads.mqtt_hello_ack(DEVICE_ID, component_id),
qos=config.MQTT_QOS
)
elif topic == sensor_topic:
mw_id = str(data.get("id_microwave"))
print(f"[MQTT] Sensor data received for microwave {mw_id}: {data}")
# Store IR data and notify the waiting dish handler
ir_data_cache[mw_id] = data
if mw_id in ir_data_events:
ir_data_events[mw_id].set()
elif source == "CloudMQTT":
topic = msg["topic"]
print(f"[CloudMQTT] Command received on '{topic}': {data}")
action = data.get("action")
if action == "request_telemetry":
endpoint = data.get("endpoint")
if endpoint:
# Schedule as a background task so processing the queue isn't stalled
do_timeout = topic == "cmd/all"
asyncio.create_task(handle_telemetry_request(endpoint, do_timeout=do_timeout))
else:
print("[CloudMQTT] Received 'request_telemetry' but missing 'endpoint' field.")
else:
print(f"[CloudMQTT] Unknown action: {action}")
# === LCD DISPLAY ===
def on_screens_change(screens, is_removed: bool, modified_name: str):
"""Callback for when screens are added or removed."""
log(f"[DisplayManager] Screens changed. Current screens: {list(screens.keys())}")
rgb_lcd_manager.set_external_screen_count(len(screens))
# Add Or remove from database of connected components
# 1. Extract id from name (assuming format "smartwave-epaper-<id>._displaytcp._tcp.local")
display_id = modified_name.split("-")[-1].split(".")[0]
if is_removed:
remove_connected_component(display_id)
else:
save_connected_component(display_id, "external_display")
display_manager.set_on_screens_change_callback(on_screens_change)
def update_lcd_microwave_count():
"""Updates the LCD with the current number of connected microwaves."""
global microwave_states
count = len(microwave_states)
rgb_lcd_manager.set_microwave_count(count)
def update_lcd_cloud_alert():
"""Updates the LCD with the current cloud connectivity status."""
global cloud_alert
rgb_lcd_manager.set_cloud_alert(cloud_alert)
# --- Button ---
def button_callback(): def button_callback():
"""Button physical interrupt callback.""" """Button physical interrupt callback."""
global button_state global button_state
@@ -296,11 +454,11 @@ button.start_button_monitoring_thread()
# --- Alert manager --- # --- Alert manager ---
alert_manager = None alert_manager = None
def on_new_alert(alert: Alert): async def on_new_alert(alert: Alert):
# Sends to cloud server # Sends to cloud server
alert_dict = alert.to_dict() alert_dict = alert.to_dict()
logs_list = asyncio.run(get_systemd_logs(lines=200, service_name="smartwave")) logs_list = await get_systemd_logs(lines=200, service_name="smartwave")
payload = { payload = {
"orchestrator_id": DEVICE_ID, "orchestrator_id": DEVICE_ID,
@@ -310,18 +468,19 @@ def on_new_alert(alert: Alert):
response = requests.post( response = requests.post(
"https://smartwave.matthiasg.dev/alert", "https://smartwave.matthiasg.dev/alert",
headers={"Content-Type": "application/json", "X-API-Key": API_KEY},
json=payload, json=payload,
) )
response.raise_for_status() response.raise_for_status()
res_json = response.json() res_json = response.json()
print(f"[Alert Handling] Alert sent to cloud: {alert_dict}, Response: {res_json}") log(f"[Alert Handling] Alert sent to cloud: {alert_dict}, Response: {res_json}")
if not alert.redistributed: # If not redistributed from microwaves if not alert.redistributed: # If not redistributed from microwaves
# Send the alert to the lora device # Send the alert to the lora device
payload = payloads.lora_new_alert(alert) payload = payloads.lora_new_alert(alert)
print(f"[Alert Handling] Sending alert to LoRa device: {payload}") print(f"[Alert Handling] Sending alert to LoRa device: {payload}")
if lora.send_reliable(payload): if lora.send_reliable(payload, max_retries=7):
microwave_states["2"].set_cooking_state(CookingStates.ALERT) microwave_states["2"].set_cooking_state(CookingStates.ALERT)
# --- HARDWARE CONTROLLERS --- # --- HARDWARE CONTROLLERS ---
@@ -417,6 +576,46 @@ async def handle_new_dish(microwave_id, detected_height):
finally: finally:
ir_data_events.pop(microwave_id, None) ir_data_events.pop(microwave_id, None)
# === API CALLS ===
async def check_rfid_card_api(card_id: str) -> bool:
"""Queries the OData API asynchronously to verify if card_id is valid."""
url = "https://smartwave.matthiasg.dev/rfid-card"
params = {
"$filter": f"card_id eq '{card_id}'"
}
headers = {"Content-Type": "application/json"}
if API_KEY:
headers["X-API-Key"] = API_KEY
try:
response = await asyncio.to_thread(
requests.get,
url,
params=params,
headers=headers,
timeout=5
)
if response.status_code == 200:
set_cloud_alert(False) # Clear cloud alert on success
data = response.json()
if isinstance(data, dict):
items = data.get("value", data.get("data", []))
return len(items) > 0 if isinstance(items, list) else bool(data)
elif isinstance(data, list):
return len(data) > 0
else:
print(f"[RFID Task] OData API returned status {response.status_code}: {response.json()}")
set_cloud_alert(True) # Set cloud alert on API connection failure
return False
except Exception as e:
print(f"[RFID Task] OData API check error: {e}")
set_cloud_alert(True) # Set cloud alert on API connection failure
return False
async def request_cloud_cooking_plan(microwave_id, sensors_data): async def request_cloud_cooking_plan(microwave_id, sensors_data):
"""Sends all data to the cloud with up to 3 retries (330s timeout for AI generation).""" """Sends all data to the cloud with up to 3 retries (330s timeout for AI generation)."""
global cloud_alert, alert_manager global cloud_alert, alert_manager
@@ -438,6 +637,7 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
for attempt in range(1, max_retries + 1): for attempt in range(1, max_retries + 1):
if microwave_states.get(microwave_id).state != MicrowaveState.WAITING_FOR_CLOUD: if microwave_states.get(microwave_id).state != MicrowaveState.WAITING_FOR_CLOUD:
print(f"[{microwave_id}] Dish removed or state changed. Aborting API request.") print(f"[{microwave_id}] Dish removed or state changed. Aborting API request.")
microwave_states[microwave_id].set_state(MicrowaveState.IDLE)
return return
try: try:
@@ -447,12 +647,14 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
requests.post, requests.post,
URL, URL,
json=sensors_data, json=sensors_data,
headers={"Content-Type": "application/json", "X-API-Key": API_KEY},
timeout=HTTP_TIMEOUT timeout=HTTP_TIMEOUT
) )
# Abort if state changed while waiting for cloud AI response # Abort if state changed while waiting for cloud AI response
if microwave_states.get(microwave_id).state != MicrowaveState.WAITING_FOR_CLOUD: if microwave_states.get(microwave_id).state != MicrowaveState.WAITING_FOR_CLOUD:
print(f"[{microwave_id}] Dish removed during API request. Discarding API plan.") print(f"[{microwave_id}] Dish removed during API request. Discarding API plan.")
microwave_states[microwave_id].set_state(MicrowaveState.IDLE)
return return
response.raise_for_status() response.raise_for_status()
@@ -468,14 +670,14 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
microwave_states[microwave_id].set_state(MicrowaveState.DONE) microwave_states[microwave_id].set_state(MicrowaveState.DONE)
if not res_json['is_safe']: # The cooking area is not safe, raise an alert if not res_json['is_safe']: # The cooking area is not safe, raise an alert
alert = Alert(AlertType.COOKING_SAFETY, res_json['warning_message']) alert = Alert(AlertType.COOKING_SAFETY, res_json['warning_message'], redistributed=False)
alert_manager.add_alert(alert) alert_manager.add_alert(alert)
else: else:
alert = Alert(AlertType.ERROR, f"Cloud API error for microwave {microwave_id}: {res_json['error']}") alert = Alert(AlertType.ERROR, f"Cloud API error for microwave {microwave_id}: {res_json['error']}")
return return
plan = res_json.get("cook_plan", res_json) plan = res_json.get("plan", {}).get("cook_plan", res_json)
c_time = plan.get("cook_time_seconds") c_time = plan.get("cook_time_seconds")
c_power = plan.get("effective_power_watts") c_power = plan.get("effective_power_watts")
@@ -489,7 +691,7 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
print(f"[{microwave_id}] Cloud Plan Received! Starting microwave: {c_time}s @ {c_power}W") print(f"[{microwave_id}] Cloud Plan Received! Starting microwave: {c_time}s @ {c_power}W")
microwave_states[microwave_id].set_state(MicrowaveState.COOKING) microwave_states[microwave_id].set_state(MicrowaveState.COOKING)
if config.DEBUG: if config.DEBUG_SHORTER_COOKING_PLAN:
c_time = 20 # Set to 20s for debug c_time = 20 # Set to 20s for debug
c_temp = 50 # Set to 50°C for debug c_temp = 50 # Set to 50°C for debug
@@ -523,108 +725,8 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
microwave_states[microwave_id].set_state(MicrowaveState.DONE) microwave_states[microwave_id].set_state(MicrowaveState.DONE)
# --- MAIN LOGIC TASKS --- # --- MAIN LOGIC TASKS ---
async def process_messages_task():
"""Consumes the unified event queue."""
# Helper to normalize config topics to str
def to_str(val):
return val.decode('utf-8') if isinstance(val, bytes) else val
while True:
msg = await async_event_queue.get()
source = msg["source"]
data = msg["data"]
if source == "LoRa":
if "new_cooking_state" in data.get("data", {}):
mw_id = data["data"].get("id")
n_state = data["data"].get("new_cooking_state")
print(f"[LoRa] Microwave {mw_id} state changed to: {n_state}")
microwave_states[mw_id].set_cooking_state(n_state)
update_lcd_microwave_count() # Update the LCD with new microwave count
if n_state in (CookingStates.DONE, CookingStates.STIRRING_REQUIRED, CookingStates.ALERT):
beats = 5 if n_state == CookingStates.ALERT else (1 if n_state == CookingStates.STIRRING_REQUIRED else 3)
buzzer.buzzer_siren(beatsNb=beats)
if n_state == CookingStates.IDLE and microwave_states.get(mw_id).state == MicrowaveState.COOKING:
microwave_states[mw_id].set_state(MicrowaveState.DONE)
print(f"[{mw_id}] Cooking finished. Waiting for user to remove dish.")
if "action" in data.get("data", {}):
if type(data["data"]) != dict:
print(f"[LoRa] Invalid data format received: {data['data']}")
continue
action = data["data"]["action"]
if action == LoraCommands.COOKING_UPDATE:
mw_id = data["data"].get("id")
if mw_id in microwave_states:
state_info = data["data"]
microwave_states[mw_id].set_cooking_state(state_info.get("cooking_state", microwave_states[mw_id].cooking_state))
microwave_states[mw_id].set_cooking_estimated_remaining_time(state_info.get("estimated_remaining_time", microwave_states[mw_id].cooking_estimated_remaining_time))
microwave_states[mw_id].set_paused(state_info.get("paused", microwave_states[mw_id].paused))
elif action == LoraCommands.NEW_ALERT:
alert_type = data["data"].get("alert_type")
alert_message = data["data"].get("alert_message")
alert = Alert(alert_type, alert_message, redistributed=True)
alert_manager.add_alert(alert)
print(f"[LoRa] New alert received: {alert.to_dict()}")
else:
print(f"[LoRa] Unhandled action received: {action} with data: {data['data']}")
elif source == "MQTT":
topic = msg["topic"]
hello_topic = to_str(config.MQTT_TOPIC_HELLO) async def handle_telemetry_request(endpoint: str, do_timeout=True):
sensor_topic = to_str(config.MQTT_TOPIC_SENSOR)
if topic == hello_topic:
if data.get("id_orchestrator") != DEVICE_ID:
component_id = data.get("id_microwave")
component_type = data.get("type", deviceTypes.DEVICE_TYPES["MICROWAVE"])
if component_id:
print(f"[MQTT] Hello received from '{component_id}' ({component_type}). Updating DB & sending ACK.")
# Offload DB insertion to async thread execution pool
await asyncio.to_thread(save_connected_component, component_id, component_type)
# Pass change callback to newly connected microwave
microwave_states[component_id] = MicrowaveState(
component_id,
on_change_callback=on_microwave_change
)
notify_display_update() # Update the external screens
update_lcd_microwave_count() # Update the LCD with new microwave count
mqtt_client.publish(
config.MQTT_TOPIC_HELLO,
payloads.mqtt_hello_ack(DEVICE_ID, component_id),
qos=config.MQTT_QOS
)
elif topic == sensor_topic:
mw_id = str(data.get("id_microwave"))
print(f"[MQTT] Sensor data received for microwave {mw_id}: {data}")
# Store IR data and notify the waiting dish handler
ir_data_cache[mw_id] = data
if mw_id in ir_data_events:
ir_data_events[mw_id].set()
elif source == "CloudMQTT":
topic = msg["topic"]
print(f"[CloudMQTT] Command received on '{topic}': {data}")
action = data.get("action")
if action == "request_telemetry":
endpoint = data.get("endpoint")
if endpoint:
# Schedule as a background task so processing the queue isn't stalled
asyncio.create_task(handle_telemetry_request(endpoint))
else:
print("[CloudMQTT] Received 'request_telemetry' but missing 'endpoint' field.")
else:
print(f"[CloudMQTT] Unknown action: {action}")
async def handle_telemetry_request(endpoint: str):
""" """
Task to gather telemetry data (including structured systemd logs) Task to gather telemetry data (including structured systemd logs)
and post it to the target HTTP endpoint. and post it to the target HTTP endpoint.
@@ -654,9 +756,10 @@ async def handle_telemetry_request(endpoint: str):
) )
# 4. Wait for he microwave turn to send the telemetry data to the cloud endpoint # 4. Wait for he microwave turn to send the telemetry data to the cloud endpoint
timeout = int(DEVICE_ID.split("_")[-1]) * config.TELEMETRY_SEND_INTERVAL if do_timeout:
print(f"[Telemetry] Waiting for {timeout}s before sending telemetry to avoid collisions...") do_timeout = int(DEVICE_ID.split("_")[-1]) * config.TELEMETRY_SEND_INTERVAL
await asyncio.sleep(timeout) print(f"[Telemetry] Waiting for {do_timeout}s before sending telemetry to avoid collisions...")
await asyncio.sleep(do_timeout)
# 5. Offload blocking HTTP POST to thread pool # 5. Offload blocking HTTP POST to thread pool
print(f"[Telemetry] Sending payload with {len(logs_list)} log entries...") print(f"[Telemetry] Sending payload with {len(logs_list)} log entries...")
@@ -664,7 +767,7 @@ async def handle_telemetry_request(endpoint: str):
requests.post, requests.post,
endpoint, endpoint,
data=telemetry_payload, # <-- Changed from json=telemetry_payload data=telemetry_payload, # <-- Changed from json=telemetry_payload
headers={"Content-Type": "application/json"}, headers={"Content-Type": "application/json", "X-API-Key": API_KEY},
timeout=15 timeout=15
) )
@@ -713,16 +816,16 @@ async def monitor_dish_height_task():
if dist is not None: if dist is not None:
# Hysteresis Thresholds: # Hysteresis Thresholds:
# - Must be > 2.5 cm to detect dish insertion # - Must be > 1.5 cm to detect dish insertion
# - Must be < 1.2 cm to detect dish removal # - Must be < 0.8 cm to detect dish removal
if dist > 2.5: if dist > 2.0:
consecutive_present += 1 consecutive_present += 1
consecutive_absent = 0 consecutive_absent = 0
elif dist < 1.2: elif dist < 1.0:
consecutive_absent += 1 consecutive_absent += 1
consecutive_present = 0 consecutive_present = 0
else: else:
# Dead-zone (1.2cm to 2.5cm) -> Noise buffer # Dead-zone (2.0cm to 1.0cm) -> Noise buffer
consecutive_present = 0 consecutive_present = 0
consecutive_absent = 0 consecutive_absent = 0
@@ -744,7 +847,7 @@ async def monitor_dish_height_task():
ir_data_cache.pop(mw_id, None) ir_data_cache.pop(mw_id, None)
ir_data_events.pop(mw_id, None) ir_data_events.pop(mw_id, None)
await asyncio.sleep(0.3) await asyncio.sleep(0.7)
# --- BOOTSTRAP --- # --- BOOTSTRAP ---
async def main(): async def main():
@@ -773,7 +876,15 @@ async def main():
cloud_mqtt_listener_task(), cloud_mqtt_listener_task(),
process_messages_task(), process_messages_task(),
monitor_dish_height_task(), monitor_dish_height_task(),
display_broadcast_worker_task() display_broadcast_worker_task(),
rfid_listener_task(),
start_technician_web_server(
get_logs_cb=get_systemd_logs,
telemetry_cb=handle_telemetry_request,
is_tech_mode_cb=is_technician_mode_active,
host=config.TECHNICIAN_WEB_SERVER_HOST,
port=config.TECHNICIAN_WEB_SERVER_PORT
)
) )
if __name__ == "__main__": if __name__ == "__main__":
@@ -782,6 +893,7 @@ if __name__ == "__main__":
except KeyboardInterrupt: except KeyboardInterrupt:
print("\nArrêt manuel.") print("\nArrêt manuel.")
finally: finally:
rfid.close() # <-- ADDED CLEANUP HERE
asyncio.run(display_manager.stop()) asyncio.run(display_manager.stop())
if hasattr(mqtt_client._client, "loop_stop"): if hasattr(mqtt_client._client, "loop_stop"):
mqtt_client._client.loop_stop() mqtt_client._client.loop_stop()
+10 -1
View File
@@ -1,7 +1,16 @@
paho-mqtt>=1.6,<3 paho-mqtt>=1.6,<3
pyserial>=3.5,<4 pyserial>=3.5,<4
zeroconf>=0.131.0
fastapi>=0.100.0,<1.0.0
uvicorn>=0.20.0,<1.0.0
python-multipart>=0.0.6,<1.0.0
requests==2.34.2
numpy>=1.20.0
# picamera2>=0.3.36,<4 # → Installed with apt install python3-picamera2 # picamera2>=0.3.36,<4 # → Installed with apt install python3-picamera2
# OpenCV # OpenCV
# sudo apt install -y python3-opencv # sudo apt install -y python3-opencv
# sudo apt install -y opencv-data # sudo apt install -y opencv-data
zeroconf>=0.131.0 # sudo apt install -y swig python3-dev liblgpio-dev
# ... Replaces this ?
# sudo apt install pigpio python3-pigpio
# sudo systemctl enable pigpiod --now
+2 -1
View File
@@ -6,4 +6,5 @@ import sensors.button as button
import sensors.gps as gps import sensors.gps as gps
import sensors.camera as camera import sensors.camera as camera
import sensors.buzzer as buzzer import sensors.buzzer as buzzer
import sensors.rgb_lcd as rgb_lcd import sensors.rgb_lcd as rgb_lcd
import sensors.rfid_reader as rfid_reader
+13 -19
View File
@@ -1,7 +1,7 @@
import grovepi import grovepi
import time import time
import threading import threading
from sensors.lock import grove_lock from sensors.lock import safe_grove_access
from shared.logging import log from shared.logging import log
button = 2 button = 2
@@ -10,28 +10,25 @@ grovepi.pinMode(button, "INPUT")
button_callback = None button_callback = None
def read_button_state(): def read_button_state():
# Increase timeout slightly so the button thread can wait for long I2C sensor reads to finish # Attempt to acquire the lock with a 0.2s timeout
if not grove_lock.acquire(timeout=0.2): with safe_grove_access(timeout=0.2) as acquired:
return None if not acquired:
try: return None
val = grovepi.digitalRead(button)
# Force strict binary output (0 or 1) try:
return 1 if val == 1 else 0 val = grovepi.digitalRead(button)
except Exception as e: # Force strict binary output (0 or 1)
log(f"BTN Error: {e}") return 1 if val == 1 else 0
return None except Exception as e:
finally: log(f"BTN Error: {e}")
grove_lock.release() return None
def monitor_button(): def monitor_button():
last_stable_state = 0 last_stable_state = 0
candidate_state = 0 candidate_state = 0
consecutive_count = 0 consecutive_count = 0
# Require 3 consecutive identical reads (~60ms) to confirm a valid state change
REQUIRED_CONSECUTIVE_READS = 3 REQUIRED_CONSECUTIVE_READS = 3
# Minimum time gap (in seconds) between allowed button triggers (cooldown)
DEBOUNCE_COOLDOWN = 0.3 DEBOUNCE_COOLDOWN = 0.3
last_trigger_time = 0 last_trigger_time = 0
@@ -45,11 +42,9 @@ def monitor_button():
candidate_state = current_state candidate_state = current_state
consecutive_count = 1 consecutive_count = 1
# State is confirmed stable across multiple reads
if consecutive_count >= REQUIRED_CONSECUTIVE_READS: if consecutive_count >= REQUIRED_CONSECUTIVE_READS:
now = time.time() now = time.time()
# Rising edge detection (0 -> 1 transition) with cooldown timer
if candidate_state == 1 and last_stable_state == 0: if candidate_state == 1 and last_stable_state == 0:
if (now - last_trigger_time) > DEBOUNCE_COOLDOWN: if (now - last_trigger_time) > DEBOUNCE_COOLDOWN:
last_trigger_time = now last_trigger_time = now
@@ -63,7 +58,6 @@ def monitor_button():
time.sleep(0.02) # 20ms poll interval time.sleep(0.02) # 20ms poll interval
else: else:
# Lock was busy or read failed; reset candidate counter to reject noisy spikes
consecutive_count = 0 consecutive_count = 0
time.sleep(0.01) time.sleep(0.01)
+45 -24
View File
@@ -1,33 +1,54 @@
import time import time
import threading
import grovepi import grovepi
from sensors.lock import grove_lock from sensors.lock import safe_grove_access
from shared import config from shared import config
# Pin definition BUZZER_PIN = 8 # Must be a PWM pin on GrovePi (D3, D5, D6, D8 support analogWrite)
BUZZER_PIN = 8
grovepi.pinMode(BUZZER_PIN, "OUTPUT") # Initialize pin mode safely
buzzer_state = 0 try:
with safe_grove_access(timeout=1.0) as acquired:
if acquired:
grovepi.pinMode(BUZZER_PIN, "OUTPUT")
except Exception as e:
print(f"[BUZZER] Init error: {e}")
def set_buzzer(state: int): def _stop_sound():
global buzzer_state """Helper to ensure sound is silenced safely under lock."""
buzzer_state = state with safe_grove_access(timeout=1.0) as acquired:
if acquired:
try:
grovepi.analogWrite(BUZZER_PIN, 0)
grovepi.digitalWrite(BUZZER_PIN, 0)
except Exception:
pass
def _siren_worker(beats_nb: int, delay: float):
if not config.BUZZER_ACTIVATED: if not config.BUZZER_ACTIVATED:
return return
try:
with grove_lock:
grovepi.digitalWrite(BUZZER_PIN, state)
except Exception as e:
print(f"[BUZZER] Error occurred while setting buzzer state: {e}")
def buzzer_toggle(): try:
global buzzer_state for _ in range(beats_nb):
new_state = 1 - buzzer_state # 1. Turn sound ON (only acquire lock briefly for the I2C write)
set_buzzer(new_state) with safe_grove_access(timeout=1.0) as acquired:
if acquired:
def buzzer_siren(beatsNb: int = 3, delay: float = 0.3): grovepi.analogWrite(BUZZER_PIN, 128)
for _ in range(beatsNb): time.sleep(delay) # Sleep WITHOUT holding the lock!
set_buzzer(1)
time.sleep(delay) # 2. Turn sound OFF
set_buzzer(0) with safe_grove_access(timeout=1.0) as acquired:
time.sleep(delay) if acquired:
grovepi.analogWrite(BUZZER_PIN, 0)
time.sleep(delay) # Sleep WITHOUT holding the lock!
except Exception as e:
print(f"[BUZZER] Error during siren execution: {e}")
finally:
_stop_sound()
def buzzer_siren(beats_nb: int = 3, delay: float = 0.2, async_run: bool = True):
if async_run:
thread = threading.Thread(target=_siren_worker, args=(beats_nb, delay), daemon=True)
thread.start()
else:
_siren_worker(beats_nb, delay)
+17 -4
View File
@@ -1,12 +1,25 @@
import grovepi
import math
from sensors.lock import grove_lock
from picamera2 import Picamera2, Preview from picamera2 import Picamera2, Preview
import time import time
picam2 = Picamera2() picam2 = Picamera2()
camera_config = picam2.create_still_configuration() # Get maximum full-sensor dimensions
sensor_format = picam2.sensor_modes[0]
max_width = sensor_format['size'][0]
max_height = sensor_format['size'][1]
camera_config = picam2.create_still_configuration(
raw={"size": (1640, 1232)},
main={"size": (1640, 1232)}
)
picam2.set_controls({
"AeEnable": True,
"AwbEnable": True,
"ExposureTime": 100000,
"ExposureValue": 0.3,
"Brightness": 0.30,
"Contrast": 1.1
})
picam2.configure(camera_config) picam2.configure(camera_config)
picam2.start() picam2.start()
+14 -13
View File
@@ -2,7 +2,7 @@ import serial
import time import time
import threading import threading
from shared.logging import log from shared.logging import log
from sensors.lock import serial_lock from sensors.lock import safe_serial_access
def calculate_nmea_checksum(line: str) -> bool: def calculate_nmea_checksum(line: str) -> bool:
"""Validates standard NMEA 0183 sentence checksum ($...*HH).""" """Validates standard NMEA 0183 sentence checksum ($...*HH)."""
@@ -34,25 +34,27 @@ class GROVEGPS:
self.longitude = -1.0 self.longitude = -1.0
def read(self): def read(self):
"""Reads the latest GGA sentence from serial, thread-safely.""" """Reads the freshest GGA sentence from serial, thread-safely."""
with serial_lock: # Allow sufficient time for the serial reads (readline can block up to timeout=1s per line)
# 1. Flush accumulated stale data in the UART buffer with safe_serial_access(timeout=2.0) as acquired:
if self.ser.in_waiting > 0: if not acquired:
self.ser.reset_input_buffer() log("GPS: Serial lock acquisition timed out")
return False
# 2. Try reading up to 15 lines to catch the freshest GGA sentence # 1. Flush accumulated stale buffer data
for _ in range(5): self.ser.reset_input_buffer()
# 2. Read through the fresh incoming stream to find a complete GGA line
for _ in range(15):
raw_bytes = self.ser.readline() raw_bytes = self.ser.readline()
try: try:
line = raw_bytes.decode('utf-8', errors='ignore').strip() line = raw_bytes.decode('utf-8', errors='ignore').strip()
# log(f"GPS: Read line: {line}") log(f"GPS: Read line: {line}")
except Exception: except Exception:
continue continue
# Supports both $GPGGA and modern $GNGGA sentences
if (line.startswith('$GPGGA') or line.startswith('$GNGGA')) and calculate_nmea_checksum(line): if (line.startswith('$GPGGA') or line.startswith('$GNGGA')) and calculate_nmea_checksum(line):
if self.parse_gga(line): return self.parse_gga(line)
return True
return False return False
def parse_gga(self, line): def parse_gga(self, line):
@@ -118,5 +120,4 @@ def get_gps_data():
else: else:
log(f"GPS: No valid fix or data available. Satellites: {gps.satellites}, Quality: {gps.quality}") log(f"GPS: No valid fix or data available. Satellites: {gps.satellites}, Quality: {gps.quality}")
# Return None so main.py doesn't process or log empty GPS data
return None return None
+27 -1
View File
@@ -1,5 +1,31 @@
import threading import threading
from contextlib import contextmanager
# Dedicated lock for I2C bus access (used by GrovePi sensors) # Dedicated lock for I2C bus access (used by GrovePi sensors)
grove_lock = threading.Lock() grove_lock = threading.Lock()
# Dedicated lock for UART/Serial port access # Dedicated lock for UART/Serial port access
serial_lock = threading.Lock() serial_lock = threading.Lock()
@contextmanager
def safe_grove_access(timeout=1.0):
"""
Safely acquire the Grove I2C lock with a timeout.
Yields True if lock acquired, False otherwise.
Guarantees lock release only when successfully acquired.
"""
acquired = grove_lock.acquire(timeout=timeout)
try:
yield acquired
finally:
if acquired:
grove_lock.release()
@contextmanager
def safe_serial_access(timeout=1.0):
"""Safely acquire the UART/Serial lock with a timeout."""
acquired = serial_lock.acquire(timeout=timeout)
try:
yield acquired
finally:
if acquired:
serial_lock.release()
+101
View File
@@ -0,0 +1,101 @@
import time
import pigpio
from typing import Optional
from sensors.lock import safe_serial_access
class RFIDReader:
"""
Grove 125kHz RFID Reader using bit-banged software serial on GPIO 17 (Pin 11).
Parses 14-byte frame: [0x02 STX] + [10 ASCII ID] + [2 ASCII Checksum] + [0x03 ETX]
"""
def __init__(self, rx_pin: int = 17, baudrate: int = 9600):
self.rx_pin = rx_pin
self.baudrate = baudrate
self.buffer = bytearray()
self.pi = pigpio.pi()
if not self.pi.connected:
raise RuntimeError("pigpio daemon is not running. Run 'sudo systemctl start pigpiod'.")
with safe_serial_access(timeout=1.0) as acquired:
if not acquired:
raise RuntimeError("RFID: Could not acquire serial lock during initialization")
self.pi.set_mode(self.rx_pin, pigpio.INPUT)
# Clean up lingering serial sessions on this pin
try:
self.pi.bb_serial_read_close(self.rx_pin)
except pigpio.error:
pass
self.pi.bb_serial_read_open(self.rx_pin, self.baudrate, 8)
def read_tag(self) -> Optional[str]:
"""
Reads and accumulates bytes, returning the 10-digit Tag ID.
"""
with safe_serial_access(timeout=0.5) as acquired:
if not acquired:
return None
if not self.pi or not self.pi.connected:
return None
count, data = self.pi.bb_serial_read(self.rx_pin)
if count > 0:
self.buffer.extend(data)
# Look for Start-of-Text (0x02)
stx_idx = self.buffer.find(b"\x02")
if stx_idx != -1:
# Discard noise prior to 0x02
if stx_idx > 0:
self.buffer = self.buffer[stx_idx:]
# Wait for full 14-byte payload
if len(self.buffer) >= 14:
raw_frame = self.buffer[:14]
self.buffer = self.buffer[14:] # Flush parsed frame
# Verify End-of-Text (0x03)
if raw_frame[-1] == 0x03:
try:
# Extract 10-character ID (indices 1 through 10)
return raw_frame[1:11].decode("ascii")
except UnicodeDecodeError:
return None
else:
# Flush buffer if filled with noise without STX marker
if len(self.buffer) > 64:
self.buffer.clear()
return None
def close(self):
with safe_serial_access(timeout=1.0) as acquired:
if self.pi and self.pi.connected:
try:
self.pi.bb_serial_read_close(self.rx_pin)
except pigpio.error:
pass
self.pi.stop()
if __name__ == "__main__":
reader = RFIDReader(rx_pin=17)
print("RFID Reader active on GPIO 17 (Pin 11). Swipe a tag...")
try:
while True:
tag = reader.read_tag()
if tag:
print(f"Scanned Tag ID: {tag}")
time.sleep(0.05)
except KeyboardInterrupt:
print("\nStopping reader.")
finally:
reader.close()
+12 -10
View File
@@ -1,6 +1,6 @@
import time import time
import sys import sys
from sensors.lock import grove_lock from sensors.lock import safe_grove_access
if sys.platform == 'uwp': if sys.platform == 'uwp':
import winrt_smbus as smbus import winrt_smbus as smbus
@@ -18,7 +18,6 @@ else:
DISPLAY_RGB_ADDR = 0x62 DISPLAY_RGB_ADDR = 0x62
DISPLAY_TEXT_ADDR = 0x3e DISPLAY_TEXT_ADDR = 0x3e
def setRGB(r, g, b, brightness=1.0): def setRGB(r, g, b, brightness=1.0):
"""Set backlight to (R,G,B) with optional brightness level (0.0 to 1.0).""" """Set backlight to (R,G,B) with optional brightness level (0.0 to 1.0)."""
brightness = max(0.0, min(1.0, float(brightness))) brightness = max(0.0, min(1.0, float(brightness)))
@@ -27,7 +26,9 @@ def setRGB(r, g, b, brightness=1.0):
g_scaled = int(max(0, min(255, g * brightness))) g_scaled = int(max(0, min(255, g * brightness)))
b_scaled = int(max(0, min(255, b * brightness))) b_scaled = int(max(0, min(255, b * brightness)))
with grove_lock: with safe_grove_access(timeout=1.0) as acquired:
if not acquired:
return
try: try:
bus.write_byte_data(DISPLAY_RGB_ADDR, 0, 0) bus.write_byte_data(DISPLAY_RGB_ADDR, 0, 0)
bus.write_byte_data(DISPLAY_RGB_ADDR, 1, 0) bus.write_byte_data(DISPLAY_RGB_ADDR, 1, 0)
@@ -38,15 +39,15 @@ def setRGB(r, g, b, brightness=1.0):
except OSError: except OSError:
pass pass
def textCommand(cmd): def textCommand(cmd):
"""Send command to display (internal use).""" """Send command to display (internal use)."""
bus.write_byte_data(DISPLAY_TEXT_ADDR, 0x80, cmd) bus.write_byte_data(DISPLAY_TEXT_ADDR, 0x80, cmd)
def setText(text): def setText(text):
"""Set display text (\n for second line or auto wrap).""" """Set display text (\n for second line or auto wrap)."""
with grove_lock: with safe_grove_access(timeout=1.5) as acquired:
if not acquired:
return
try: try:
textCommand(0x01) # Clear display textCommand(0x01) # Clear display
time.sleep(0.05) time.sleep(0.05)
@@ -67,14 +68,15 @@ def setText(text):
continue continue
count += 1 count += 1
bus.write_byte_data(DISPLAY_TEXT_ADDR, 0x40, ord(c)) bus.write_byte_data(DISPLAY_TEXT_ADDR, 0x40, ord(c))
time.sleep(0.001) # Small pacing delay to prevent LCD buffer overflow time.sleep(0.001)
except OSError: except OSError:
pass pass
def setText_norefresh(text): def setText_norefresh(text):
"""Update display text without full screen erase.""" """Update display text without full screen erase."""
with grove_lock: with safe_grove_access(timeout=1.5) as acquired:
if not acquired:
return
try: try:
textCommand(0x02) # Return home textCommand(0x02) # Return home
time.sleep(0.05) time.sleep(0.05)
@@ -97,6 +99,6 @@ def setText_norefresh(text):
continue continue
count += 1 count += 1
bus.write_byte_data(DISPLAY_TEXT_ADDR, 0x40, ord(c)) bus.write_byte_data(DISPLAY_TEXT_ADDR, 0x40, ord(c))
time.sleep(0.001) # Small pacing delay to prevent LCD buffer overflow time.sleep(0.001)
except OSError: except OSError:
pass pass
+1 -1
View File
@@ -24,7 +24,7 @@ def get_temperature_and_humidity():
return None, None return None, None
def get_temperature_and_humidity_with_retry(max_retries=4, retry_delay=1.5): def get_temperature_and_humidity_with_retry(max_retries=4, retry_delay=2.5):
""" """
Reads temperature and humidity with retries. Reads temperature and humidity with retries.
Uses a 1.5s delay between attempts to respect DHT sampling limits. Uses a 1.5s delay between attempts to respect DHT sampling limits.
+13 -22
View File
@@ -1,33 +1,24 @@
import grovepi import grovepi
from sensors.lock import grove_lock from sensors.lock import safe_grove_access
from shared import config from shared import config
# Connect the Grove Ultrasonic Ranger to digital port D4
# SIG,NC,VCC,GND
ULTRASONIC_RANGER_PORT = 4 ULTRASONIC_RANGER_PORT = 4
def read_ultrasonic_ranger(ultrasonic_ranger=ULTRASONIC_RANGER_PORT): def read_ultrasonic_ranger(ultrasonic_ranger=ULTRASONIC_RANGER_PORT):
if not grove_lock.acquire(timeout=1.0): with safe_grove_access(timeout=1.5) as acquired:
print("Ultrasonic: Lock acquisition timed out") if not acquired:
return None return None
try: try:
return grovepi.ultrasonicRead(ultrasonic_ranger) return grovepi.ultrasonicRead(ultrasonic_ranger)
except Exception as e: except Exception as e:
print(f"Error: {e}") print(f"Ultrasonic read error: {e}")
return None return None
finally:
grove_lock.release()
def get_dish_height(): def get_dish_height():
"""Returns the height of the dish in centimeters.""" """Returns the height of the dish in centimeters."""
distance = read_ultrasonic_ranger() distance = read_ultrasonic_ranger()
if distance is not None: if distance is not None:
# Assuming the ultrasonic sensor is mounted at a fixed height above the dish
# and pointing downwards, we can calculate the height of the dish.
# For example, if the sensor is 30 cm above the dish when it's empty:
# cm
dish_height = config.COOKING_COMPARTMENT_HEIGHT - distance dish_height = config.COOKING_COMPARTMENT_HEIGHT - distance
return max(dish_height, 0) # Ensure height is not negative return max(dish_height, 0)
else: return None
return None
+1
View File
@@ -9,6 +9,7 @@ User=pi
Group=pi Group=pi
WorkingDirectory=/home/pi/SmartWave WorkingDirectory=/home/pi/SmartWave
EnvironmentFile=-/etc/default/smartwave EnvironmentFile=-/etc/default/smartwave
Environment="PYTHONUNBUFFERED=1"
ExecStart=/bin/sh /home/pi/SmartWave/orchestrateur/launch.sh /home/pi/SmartWave/orchestrateur/main.py ExecStart=/bin/sh /home/pi/SmartWave/orchestrateur/launch.sh /home/pi/SmartWave/orchestrateur/main.py
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
+159
View File
@@ -0,0 +1,159 @@
import asyncio
from fastapi import FastAPI, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse
import uvicorn
HTML_TEMPLATE = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SmartWave Technician Portal</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: "Segoe UI", Roboto, monospace; background: #121212; color: #e0e0e0; height: 100vh; overflow: hidden; }
.container { display: flex; height: 100vh; }
.logs-section { flex: 1; display: flex; flex-direction: column; border-right: 1px solid #333; padding: 15px; }
.logs-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.logs-header h2 { font-size: 1.1rem; color: #4fc3f7; }
.logs-header button { background: #333; color: #fff; border: 1px solid #555; padding: 4px 10px; border-radius: 4px; cursor: pointer; }
pre#log-output { flex: 1; background: #000; color: #00ff66; padding: 12px; border-radius: 6px; overflow-y: auto; font-size: 0.85rem; line-height: 1.4; white-space: pre-wrap; word-break: break-all; }
.control-section { width: 500px; padding: 20px; background: #1a1a1a; display: flex; flex-direction: column; gap: 15px; }
.control-section h2 { font-size: 1.1rem; color: #ffb74d; margin-bottom: 5px; }
.form-group { display: flex; flex-direction: column; gap: 6px; }
label { font-size: 0.85rem; color: #aaa; }
input[type="text"] { background: #2a2a2a; border: 1px solid #444; color: #fff; padding: 10px; border-radius: 4px; font-size: 0.9rem; }
input[type="text"]:focus { outline: none; border-color: #ffb74d; }
button.btn-submit { background: #ffb74d; color: #121212; border: none; padding: 12px; font-weight: bold; border-radius: 4px; cursor: pointer; transition: background 0.2s; }
button.btn-submit:hover { background: #ffa726; }
#status-msg { font-size: 0.85rem; padding: 8px; border-radius: 4px; display: none; }
.success { background: #1b5e20; color: #a5d6a7; }
.error { background: #b71c1c; color: #ef9a9a; }
</style>
</head>
<body>
<div class="container">
<div class="logs-section">
<div class="logs-header">
<h2>SmartWave Orchestrator System Logs</h2>
<button onclick="fetchLogs()">Refresh</button>
</div>
<pre id="log-output">Loading logs...</pre>
</div>
<div class="control-section">
<h2>Telemetry Control</h2>
<div class="form-group">
<label for="endpoint">Telemetry Endpoint URL:</label>
<input type="text" id="endpoint" value="https://smartwave.matthiasg.dev/telemetry">
</div>
<button class="btn-submit" onclick="triggerTelemetry()">Send Telemetry</button>
<div id="status-msg"></div>
</div>
</div>
<script>
async function fetchLogs() {
try {
const res = await fetch('/api/logs');
const logs = await res.json();
const logContainer = document.getElementById('log-output');
logContainer.textContent = logs.map(entry => {
const ts = entry.timestamp ? entry.timestamp.replace('T', ' ').split('.')[0] : 'RAW';
const pid = entry.pid ? `[PID:${entry.pid}]` : '';
return `${ts} ${pid} ${entry.message}`;
}).join('\\n');
logContainer.scrollTop = logContainer.scrollHeight;
} catch (err) {
console.error("Failed to fetch logs", err);
}
}
async function triggerTelemetry() {
const endpoint = document.getElementById('endpoint').value;
const statusDiv = document.getElementById('status-msg');
statusDiv.style.display = 'block';
statusDiv.className = '';
statusDiv.textContent = 'Sending telemetry...';
try {
const formData = new FormData();
formData.append('endpoint', endpoint);
const res = await fetch('/api/telemetry', {
method: 'POST',
body: formData
});
const data = await res.json();
if (res.ok && data.status === 'ok') {
statusDiv.className = 'success';
statusDiv.textContent = 'Telemetry sent successfully!';
} else {
throw new Error(data.detail || 'Failed');
}
} catch (err) {
statusDiv.className = 'error';
statusDiv.textContent = 'Error sending telemetry: ' + err.message;
}
}
fetchLogs();
setInterval(fetchLogs, 3000);
</script>
</body>
</html>
"""
def create_app(get_logs_cb, telemetry_cb, is_tech_mode_cb) -> FastAPI:
"""Factory creating FastAPI app with injected handler callbacks."""
app = FastAPI(title="Technician Portal")
# Attach callbacks to app.state
app.state.get_systemd_logs = get_logs_cb
app.state.handle_telemetry_request = telemetry_cb
app.state.is_tech_mode = is_tech_mode_cb
# Middleware to block traffic when TECHNICIAN_MODE is False
@app.middleware("http")
async def enforce_technician_mode(request: Request, call_next):
if not request.app.state.is_tech_mode():
return JSONResponse(
status_code=503,
content={"detail": "Technician mode is disabled."}
)
return await call_next(request)
@app.get("/", response_class=HTMLResponse)
async def serve_dashboard():
return HTML_TEMPLATE
@app.get("/api/logs")
async def api_get_logs(request: Request):
try:
logs = await request.app.state.get_systemd_logs(lines=200, service_name="smartwave")
return JSONResponse(content=logs)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/telemetry")
async def api_trigger_telemetry(request: Request, endpoint: str = Form(...)):
try:
await request.app.state.handle_telemetry_request(endpoint=endpoint, do_timeout=True)
return {"status": "ok", "message": f"Telemetry request sent to {endpoint}"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Telemetry trigger failed: {str(e)}")
return app
async def start_technician_web_server(get_logs_cb, telemetry_cb, is_tech_mode_cb, host="192.168.50.1", port=8080):
"""Asynchronous server launcher meant to run within main.py's event loop."""
app = create_app(get_logs_cb, telemetry_cb, is_tech_mode_cb)
config = uvicorn.Config(app, host=host, port=port, log_level="info")
server = uvicorn.Server(config)
await server.serve()
+20 -2
View File
@@ -1,4 +1,10 @@
import time import time
try:
import asyncio
import inspect
ASYNCIO = True
except ImportError:
ASYNCIO = False
class AlertManager: class AlertManager:
def __init__(self): def __init__(self):
@@ -8,10 +14,22 @@ class AlertManager:
def set_on_alert_callback(self, callback): def set_on_alert_callback(self, callback):
self.on_alert_callback = callback self.on_alert_callback = callback
def call_on_alert_callback(self, alert):
if self.on_alert_callback:
if ASYNCIO and inspect.iscoroutinefunction(self.on_alert_callback):
# Schedule coroutine on the running loop without blocking
try:
loop = asyncio.get_running_loop()
loop.create_task(self.on_alert_callback(alert))
except RuntimeError:
# Fallback if called outside an active event loop
asyncio.run(self.on_alert_callback(alert))
else:
self.on_alert_callback(alert)
def add_alert(self, alert): def add_alert(self, alert):
self.alerts.append(alert) self.alerts.append(alert)
if self.on_alert_callback: self.call_on_alert_callback(alert)
self.on_alert_callback(alert)
def get_alerts(self): def get_alerts(self):
return self.alerts return self.alerts
+9 -2
View File
@@ -1,7 +1,14 @@
# Debugging and others # Debugging and others
DEBUG=True DEBUG=True
DEBUG_MESSAGES=True
DEBUG_DANGEROUS_AREA=False DEBUG_DANGEROUS_AREA=False
DEBUG_TEMPERATURE_ALERT=False DEBUG_TEMPERATURE_ALERT=False
DEBUG_SHORTER_COOKING_PLAN=True
DEBUG_DISHANALYZER=True
# Technitian Web Server
TECHNICIAN_WEB_SERVER_HOST = "192.168.50.1"
TECHNICIAN_WEB_SERVER_PORT = 8080
# LoRa # LoRa
LORA_HEARTBEAT_INTERVAL = 30 LORA_HEARTBEAT_INTERVAL = 30
@@ -19,8 +26,8 @@ CLOUD_MQTT_QOS = 2
MQTT_HELLO_INTERVAL = 30 MQTT_HELLO_INTERVAL = 30
# Microwave Model # Microwave Model
COOKING_COMPARTMENT_HEIGHT = 30 # cm COOKING_COMPARTMENT_HEIGHT = 20.5 # cm
BUZZER_ACTIVATED = False BUZZER_ACTIVATED = True
MAX_ALLOWED_TEMPERATURE_TECHNICAL_COMPARTMENT = 70 MAX_ALLOWED_TEMPERATURE_TECHNICAL_COMPARTMENT = 70
MAX_ALLOWED_TEMPERATURE_COOKING_COMPARTMENT = 200 MAX_ALLOWED_TEMPERATURE_COOKING_COMPARTMENT = 200
+2 -2
View File
@@ -1,6 +1,6 @@
from shared.config import DEBUG from shared.config import DEBUG_MESSAGES
def log(message): def log(message):
"""Log a message to the console if DEBUG is enabled.""" """Log a message to the console if DEBUG is enabled."""
if DEBUG: if DEBUG_MESSAGES:
print(f"\n{message}") print(f"\n{message}")
+1 -1
View File
@@ -81,7 +81,7 @@ class BaseLoraDevice:
return packet return packet
def send_reliable(self, payload, max_retries=4, ack_timeout=3.0): def send_reliable(self, payload, max_retries=5, ack_timeout=3.0):
"""Sends a payload and listens in a single continuous RX window for the ACK.""" """Sends a payload and listens in a single continuous RX window for the ACK."""
with self.tx_lock: with self.tx_lock: