Compare commits
59 Commits
d7833100a5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e68ffd9b8b | |||
| 49fd566457 | |||
| dd37465546 | |||
| 3753f57041 | |||
| 688fd26db8 | |||
| 4d7706f8e1 | |||
| efbc147f48 | |||
| 4085305039 | |||
| fed99772fc | |||
| 204f271fc2 | |||
| 5c60de3f03 | |||
| 8a71d4eaa7 | |||
| eb80d13300 | |||
| 6e2f849ee2 | |||
| 68250e8069 | |||
| fe31ac978a | |||
| 3c50fc4d0e | |||
| 3aed0e5f00 | |||
| f55336a636 | |||
| c58ae4603e | |||
| e7f6374200 | |||
| 9053351674 | |||
| 1f53e7e6c3 | |||
| 7c35b52dbd | |||
| 0f576467e1 | |||
| 9d1a1f4c78 | |||
| 701e7f1491 | |||
| e652831ce5 | |||
| ebc28546bc | |||
| a51bfc1388 | |||
| 7082ea3f8b | |||
| e802386adf | |||
| 289124b7f8 | |||
| 3bead59b23 | |||
| 467740ba84 | |||
| 29c943ca5a | |||
| c130566156 | |||
| 178457e199 | |||
| c860afc609 | |||
| 9562bb7315 | |||
| a769efa8e0 | |||
| 3711928c7d | |||
| 6b8c1688a6 | |||
| 755ffdad46 | |||
| 29fcb26eae | |||
| 8b3c1d6b63 | |||
| c39b811a3c | |||
| b0a6c4d2ec | |||
| 0b1971ed0b | |||
| d6951cbbcf | |||
| c6cec3dede | |||
| e67a13b4c8 | |||
| 0d394eca1e | |||
| c76379230b | |||
| e042162a77 | |||
| f167f4a2cd | |||
| 7add88f154 | |||
| bb5327fb93 | |||
| 022c45edee |
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
+9
-3
@@ -29,10 +29,16 @@ curl -X POST "https://smartwave.matthiasg.dev/alert" \
|
|||||||
## Jobs
|
## Jobs
|
||||||
|
|
||||||
### Trigger Server CVE Audit
|
### Trigger Server CVE Audit
|
||||||
`curl http://localhost:5000/debug/run-job/server_cve_audit_job`
|
`curl https://smartwave.matthiasg.dev/debug/run-job/server_cve_audit_job`
|
||||||
|
|
||||||
### Trigger Client IP Audit
|
### Trigger Client IP Audit
|
||||||
`curl http://localhost:5000/debug/run-job/client_ip_audit_job`
|
`curl https://smartwave.matthiasg.dev/debug/run-job/client_ip_audit_job`
|
||||||
|
|
||||||
### Trigger Telemetry Request
|
### Trigger Telemetry Request
|
||||||
`curl http://localhost:5000/debug/run-job/request_telemetry_job`
|
`curl https://smartwave.matthiasg.dev/debug/run-job/request_telemetry_job`
|
||||||
|
|
||||||
|
# Faux bols
|
||||||
|
|
||||||
|
- Riz
|
||||||
|
|
||||||
|
- Pot Beef Burgundy
|
||||||
+110
-84
@@ -17,6 +17,7 @@ from APIs.mqtt import send_command
|
|||||||
from APIs.webex import WebexManager
|
from APIs.webex import WebexManager
|
||||||
from APIs.shodan import ShodanAuditor
|
from APIs.shodan import ShodanAuditor
|
||||||
from APIs.twilio import send_alert_sms
|
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
|
||||||
from jobs import job_server_cve_audit, job_client_ip_audit, job_request_telemetry
|
from jobs import job_server_cve_audit, job_client_ip_audit, job_request_telemetry
|
||||||
@@ -153,7 +154,7 @@ start_scheduler_once()
|
|||||||
# ---------------------------------------------------------
|
# ---------------------------------------------------------
|
||||||
# Authentication Middleware
|
# Authentication Middleware
|
||||||
# ---------------------------------------------------------
|
# ---------------------------------------------------------
|
||||||
EXEMPT_ROUTES = {'hello_world', 'oauth_callback', 'odata_metadata'}
|
EXEMPT_ROUTES = {'hello_world', 'oauth_callback', 'odata_metadata', 'debug_telemetryrequest', 'trigger_job_manually', 'debug_unsafe_dish', 'debug_safe_dish'}
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def authenticate_request():
|
def authenticate_request():
|
||||||
@@ -219,7 +220,7 @@ async def cooking_params():
|
|||||||
if not data or not isinstance(data, dict):
|
if not data or not isinstance(data, dict):
|
||||||
return jsonify({"@odata.error": {"code": "400", "message": "Invalid payload"}}), 400
|
return jsonify({"@odata.error": {"code": "400", "message": "Invalid payload"}}), 400
|
||||||
|
|
||||||
data["microwave_id"] = g.device_id
|
# data["microwave_id"] = g.device_id
|
||||||
|
|
||||||
forwarded_for = request.headers.get('X-Forwarded-For')
|
forwarded_for = request.headers.get('X-Forwarded-For')
|
||||||
client_ip = forwarded_for.split(',')[0].strip() if forwarded_for else request.remote_addr
|
client_ip = forwarded_for.split(',')[0].strip() if forwarded_for else request.remote_addr
|
||||||
@@ -245,12 +246,14 @@ async def cooking_params():
|
|||||||
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
|
||||||
|
|
||||||
|
enhanced_filepath = enhance_image_for_ai(filepath)
|
||||||
|
|
||||||
# Concurrent AI Execution
|
# Concurrent AI Execution
|
||||||
safety_task = asyncio.to_thread(safety_checker.check_dish_safety, filepath)
|
safety_task = asyncio.to_thread(safety_checker.check_dish_safety, filepath)
|
||||||
planner_task = asyncio.to_thread(
|
planner_task = asyncio.to_thread(
|
||||||
microwave_cook_planner.generate_plan,
|
microwave_cook_planner.generate_plan,
|
||||||
image_path=filepath,
|
image_path=enhanced_filepath,
|
||||||
height_cm=height_cm,
|
height_cm=height_cm,
|
||||||
initial_temp_c=initial_temp_c,
|
initial_temp_c=initial_temp_c,
|
||||||
microwave_wattage=microwave_wattage,
|
microwave_wattage=microwave_wattage,
|
||||||
@@ -275,6 +278,7 @@ async def cooking_params():
|
|||||||
|
|
||||||
if not safety_result.get("is_safe", True):
|
if not safety_result.get("is_safe", True):
|
||||||
odata_response.update({
|
odata_response.update({
|
||||||
|
"error": "Unsafe cooking 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", [])
|
||||||
@@ -319,97 +323,101 @@ def 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")
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
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"
|
||||||
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", 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=doc_id,
|
"device_type": "orchestrator",
|
||||||
added_alert_info=True
|
"device_id": str(orchestrator_id)
|
||||||
)
|
}
|
||||||
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):
|
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", {})
|
||||||
room_link = room_details.get("meetingLink") or f"https://web.webex.com/spaces/{webex_room_id}"
|
client_email = contact_info.get("email")
|
||||||
else:
|
client_phone = contact_info.get("phone")
|
||||||
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:
|
|
||||||
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:
|
three_minutes_ago = datetime.now(timezone.utc) - timedelta(minutes=3)
|
||||||
sms_sent = send_alert_sms(
|
|
||||||
to_phone=client_phone,
|
recent_alert = alert_collection.find_one({
|
||||||
room_title=room_title,
|
"orchestrator_id": orchestrator_id,
|
||||||
room_link=room_link,
|
"webex_room_id": {"$exists": True, "$ne": None},
|
||||||
client_email=client_email
|
"created_at": {"$gte": three_minutes_ago},
|
||||||
)
|
"_id": {"$ne": saved_alert.inserted_id}
|
||||||
|
}, sort=[("created_at", DESCENDING)])
|
||||||
|
|
||||||
update_fields = {"sms_sent": sms_sent}
|
if recent_alert:
|
||||||
if webex_room_id:
|
webex_room_id = recent_alert["webex_room_id"]
|
||||||
update_fields.update({
|
room_title = recent_alert.get("webex_room_title", room_title)
|
||||||
"webex_room_id": webex_room_id,
|
try:
|
||||||
"webex_room_title": room_title,
|
webex_manager.send_alert_info_message(
|
||||||
"webex_room_link": room_link
|
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}"
|
||||||
|
|
||||||
alert_collection.update_one({"_id": saved_alert.inserted_id}, {"$set": update_fields})
|
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
|
# OData v4 Created Response Payload
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -583,6 +591,24 @@ def trigger_job_manually(job_id):
|
|||||||
"status": "error",
|
"status": "error",
|
||||||
"message": f"Job execution failed: {str(e)}"
|
"message": f"Job execution failed: {str(e)}"
|
||||||
}), 500
|
}), 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"
|
||||||
|
|
||||||
|
# Call the cooking_params endpoint logic directly
|
||||||
|
return safety_checker.check_dish_safety(unsafe_dish)
|
||||||
|
|
||||||
|
@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
|
# OData Metadata Definition
|
||||||
@@ -638,4 +664,4 @@ def odata_metadata():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(debug=getattr(config, "DEBUG", True))
|
app.run(debug=getattr(config, "DEBUG", False))
|
||||||
@@ -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
|
||||||
+1
-1
@@ -35,7 +35,7 @@ def job_client_ip_audit(shodan_auditor, db):
|
|||||||
}}
|
}}
|
||||||
]
|
]
|
||||||
|
|
||||||
recent_clients = list(db.device_ip_collection.aggregate(pipeline))
|
recent_clients = list(db.device_ips.aggregate(pipeline))
|
||||||
|
|
||||||
for entry in recent_clients:
|
for entry in recent_clients:
|
||||||
client_ip = entry["_id"]
|
client_ip = entry["_id"]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+15
-20
@@ -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,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
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+17
-12
@@ -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"
|
||||||
+47
-36
@@ -3,6 +3,7 @@ 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 shared import get_lora, get_mqtt_client, deviceTypes, config, payloads, db
|
from shared import get_lora, get_mqtt_client, deviceTypes, config, payloads, db
|
||||||
@@ -14,6 +15,7 @@ from sensors import ultrasonicRanger, temp_hum, button, camera, buzzer, rgb_lcd,
|
|||||||
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")
|
||||||
@@ -21,9 +23,12 @@ 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
|
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."""
|
||||||
@@ -73,7 +78,7 @@ def get_device_id():
|
|||||||
return "RPI_Orchestrateur_Default"
|
return "RPI_Orchestrateur_Default"
|
||||||
|
|
||||||
def get_api_key():
|
def get_api_key():
|
||||||
for path in ["api_key.txt", "/home/pi/SmartWave/orchestrateur/api_key.txt"]:
|
for path in ["device_api_key.txt", "/home/pi/SmartWave/orchestrateur/device_api_key.txt"]:
|
||||||
try:
|
try:
|
||||||
with open(path, "r") as f:
|
with open(path, "r") as f:
|
||||||
return f.read().strip()
|
return f.read().strip()
|
||||||
@@ -101,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."""
|
||||||
@@ -247,7 +261,7 @@ async def rfid_listener_task():
|
|||||||
tag_id = await asyncio.to_thread(rfid.read_tag)
|
tag_id = await asyncio.to_thread(rfid.read_tag)
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
|
|
||||||
if tag_id:
|
if tag_id and tag_id != "0000000000":
|
||||||
# Debounce same tag within 3 seconds to avoid spamming the API
|
# Debounce same tag within 3 seconds to avoid spamming the API
|
||||||
if tag_id == last_scanned_tag and (current_time - last_scan_time) < 3.0:
|
if tag_id == last_scanned_tag and (current_time - last_scan_time) < 3.0:
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
@@ -263,17 +277,17 @@ async def rfid_listener_task():
|
|||||||
TECHNICIAN_MODE = True
|
TECHNICIAN_MODE = True
|
||||||
print(f"[RFID] Card {tag_id} VALIDATED! -> TECHNICIAN_MODE = TRUE")
|
print(f"[RFID] Card {tag_id} VALIDATED! -> TECHNICIAN_MODE = TRUE")
|
||||||
# Optional: Sound positive feedback beep
|
# Optional: Sound positive feedback beep
|
||||||
buzzer.buzzer_siren(beatsNb=1)
|
buzzer.buzzer_siren(beats_nb=2)
|
||||||
else:
|
else:
|
||||||
print(f"[RFID] Card {tag_id} REJECTED / Invalid.")
|
print(f"[RFID] Card {tag_id} REJECTED / Invalid.")
|
||||||
# Optional: Sound error feedback beep
|
# Optional: Sound error feedback beep
|
||||||
buzzer.buzzer_siren(beatsNb=2)
|
buzzer.buzzer_siren(beats_nb=4)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[RFID Task] Error reading RFID tag: {e}")
|
print(f"[RFID Task] Error reading RFID tag: {e}")
|
||||||
|
|
||||||
# Sleep 100ms between checks to keep CPU usage near zero
|
# Sleep 100ms between checks to keep CPU usage near zero
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.2)
|
||||||
|
|
||||||
async def display_broadcast_worker_task():
|
async def display_broadcast_worker_task():
|
||||||
"""
|
"""
|
||||||
@@ -319,20 +333,6 @@ async def process_messages_task():
|
|||||||
data = msg["data"]
|
data = msg["data"]
|
||||||
|
|
||||||
if source == "LoRa":
|
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 "action" in data.get("data", {}):
|
||||||
if type(data["data"]) != dict:
|
if type(data["data"]) != dict:
|
||||||
print(f"[LoRa] Invalid data format received: {data['data']}")
|
print(f"[LoRa] Invalid data format received: {data['data']}")
|
||||||
@@ -454,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,
|
||||||
@@ -474,13 +474,13 @@ def on_new_alert(alert: Alert):
|
|||||||
|
|
||||||
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 ---
|
||||||
@@ -606,7 +606,9 @@ async def check_rfid_card_api(card_id: str) -> bool:
|
|||||||
return len(items) > 0 if isinstance(items, list) else bool(data)
|
return len(items) > 0 if isinstance(items, list) else bool(data)
|
||||||
elif isinstance(data, list):
|
elif isinstance(data, list):
|
||||||
return len(data) > 0
|
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
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -635,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:
|
||||||
@@ -651,6 +654,7 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
|
|||||||
# 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()
|
||||||
@@ -666,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")
|
||||||
@@ -687,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
|
||||||
|
|
||||||
@@ -812,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
|
||||||
|
|
||||||
@@ -843,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():
|
||||||
@@ -873,7 +877,14 @@ async def main():
|
|||||||
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() # <-- ADDED HERE
|
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__":
|
||||||
|
|||||||
@@ -1,9 +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 apt install pigpio python3-pigpio
|
||||||
# sudo systemctl enable pigpiod --now
|
# sudo systemctl enable pigpiod --now
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)."""
|
||||||
@@ -35,7 +35,12 @@ class GROVEGPS:
|
|||||||
|
|
||||||
def read(self):
|
def read(self):
|
||||||
"""Reads the freshest 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)
|
||||||
|
with safe_serial_access(timeout=2.0) as acquired:
|
||||||
|
if not acquired:
|
||||||
|
log("GPS: Serial lock acquisition timed out")
|
||||||
|
return False
|
||||||
|
|
||||||
# 1. Flush accumulated stale buffer data
|
# 1. Flush accumulated stale buffer data
|
||||||
self.ser.reset_input_buffer()
|
self.ser.reset_input_buffer()
|
||||||
|
|
||||||
@@ -115,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
|
||||||
@@ -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()
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import time
|
import time
|
||||||
import pigpio
|
import pigpio
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from sensors.lock import serial_lock
|
from sensors.lock import safe_serial_access
|
||||||
|
|
||||||
|
|
||||||
class RFIDReader:
|
class RFIDReader:
|
||||||
@@ -19,7 +19,10 @@ class RFIDReader:
|
|||||||
if not self.pi.connected:
|
if not self.pi.connected:
|
||||||
raise RuntimeError("pigpio daemon is not running. Run 'sudo systemctl start pigpiod'.")
|
raise RuntimeError("pigpio daemon is not running. Run 'sudo systemctl start pigpiod'.")
|
||||||
|
|
||||||
with serial_lock:
|
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)
|
self.pi.set_mode(self.rx_pin, pigpio.INPUT)
|
||||||
|
|
||||||
# Clean up lingering serial sessions on this pin
|
# Clean up lingering serial sessions on this pin
|
||||||
@@ -34,7 +37,10 @@ class RFIDReader:
|
|||||||
"""
|
"""
|
||||||
Reads and accumulates bytes, returning the 10-digit Tag ID.
|
Reads and accumulates bytes, returning the 10-digit Tag ID.
|
||||||
"""
|
"""
|
||||||
with serial_lock:
|
with safe_serial_access(timeout=0.5) as acquired:
|
||||||
|
if not acquired:
|
||||||
|
return None
|
||||||
|
|
||||||
if not self.pi or not self.pi.connected:
|
if not self.pi or not self.pi.connected:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -70,7 +76,7 @@ class RFIDReader:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
with serial_lock:
|
with safe_serial_access(timeout=1.0) as acquired:
|
||||||
if self.pi and self.pi.connected:
|
if self.pi and self.pi.connected:
|
||||||
try:
|
try:
|
||||||
self.pi.bb_serial_read_close(self.rx_pin)
|
self.pi.bb_serial_read_close(self.rx_pin)
|
||||||
|
|||||||
@@ -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,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
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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}")
|
||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user