Compare commits

...

39 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
26 changed files with 386 additions and 269 deletions
+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
) )
+87 -83
View File
@@ -220,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
@@ -250,7 +250,7 @@ async def cooking_params():
enhanced_filepath = enhance_image_for_ai(filepath) enhanced_filepath = enhance_image_for_ai(filepath)
# Concurrent AI Execution # Concurrent AI Execution
safety_task = asyncio.to_thread(safety_checker.check_dish_safety, enhanced_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=enhanced_filepath, image_path=enhanced_filepath,
@@ -323,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) if alert_type != "COOKING_SAFETY":
recent_alert = alert_collection.find_one({ client_name = f"Unknown Client (Orchestrator {orchestrator_id})" if orchestrator_id else "Unknown Client"
"orchestrator_id": orchestrator_id, client_email = None
"webex_room_id": {"$exists": True, "$ne": None}, client_phone = None
"created_at": {"$gte": three_minutes_ago},
"_id": {"$ne": saved_alert.inserted_id}
}, sort=[("created_at", DESCENDING)])
if recent_alert: if orchestrator_id:
webex_room_id = recent_alert["webex_room_id"] client_doc = client_collection.find_one({
room_title = recent_alert.get("webex_room_title", room_title) "devices": {
try: "$elemMatch": {
webex_manager.send_alert_info_message( "device_type": "orchestrator",
room_id=webex_room_id, "device_id": str(orchestrator_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): 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,
room_title=room_title,
room_link=room_link,
client_email=client_email
)
update_fields = {"sms_sent": sms_sent} recent_alert = alert_collection.find_one({
if webex_room_id: "orchestrator_id": orchestrator_id,
update_fields.update({ "webex_room_id": {"$exists": True, "$ne": None},
"webex_room_id": webex_room_id, "created_at": {"$gte": three_minutes_ago},
"webex_room_title": room_title, "_id": {"$ne": saved_alert.inserted_id}
"webex_room_link": room_link }, sort=[("created_at", DESCENDING)])
})
alert_collection.update_one({"_id": saved_alert.inserted_id}, {"$set": update_fields}) 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 # OData v4 Created Response Payload
return jsonify({ return jsonify({
@@ -601,7 +605,7 @@ def debug_unsafe_dish():
def debug_safe_dish(): def debug_safe_dish():
"""Debug endpoint to simulate a safe dish scenario.""" """Debug endpoint to simulate a safe dish scenario."""
# Simulated safe dish data # Simulated safe dish data
unsafe_dish = "storage/dishPhotos/dish_def0047482e6430488dbf9a47d73271a.jpg" unsafe_dish = "storage/dishPhotos/dish_af60c26a08a74ce894d453394f417c22.jpg"
# Call the cooking_params endpoint logic directly # Call the cooking_params endpoint logic directly
return safety_checker.check_dish_safety(unsafe_dish) return safety_checker.check_dish_safety(unsafe_dish)
@@ -660,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))
+10 -7
View File
@@ -1,23 +1,26 @@
import cv2 import cv2
def enhance_image_for_ai(input_path: str) -> str: def enhance_image_for_ai(input_path: str) -> str:
"""Enhances dark areas and contrast using CLAHE without blowing out bright areas.""" """Smooths camera sensor noise and brightens dark areas for vision LLMs."""
img = cv2.imread(input_path) img = cv2.imread(input_path)
if img is None: if img is None:
return input_path return input_path
# Convert to LAB color space to modify luminance channel only # 1. Remove ISO sensor noise (grain)
lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB) 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) l, a, b = cv2.split(lab)
# Apply CLAHE to Lightness channel # 3. Apply softer CLAHE (lower clipLimit prevents noise amplification)
clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8)) clahe = cv2.createCLAHE(clipLimit=1.8, tileGridSize=(8, 8))
cl = clahe.apply(l) cl = clahe.apply(l)
# Merge channels and convert back to BGR # 4. Merge channels back
limg = cv2.merge((cl, a, b)) limg = cv2.merge((cl, a, b))
enhanced = cv2.cvtColor(limg, cv2.COLOR_LAB2BGR) enhanced = cv2.cvtColor(limg, cv2.COLOR_LAB2BGR)
enhanced_path = input_path.replace(".jpg", "_enhanced.jpg") enhanced_path = input_path.replace(".jpg", "_enhanced.jpg")
cv2.imwrite(enhanced_path, enhanced) cv2.imwrite(enhanced_path, enhanced, [cv2.IMWRITE_JPEG_QUALITY, 90])
return enhanced_path return enhanced_path
+17
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:
@@ -42,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 -27
View File
@@ -3,41 +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 UtensilMaterialCheck(BaseModel):
object_name: str = Field(
description="Name of utensil or container seen (e.g., spoon, fork, tray)."
)
is_metal: bool = Field(
description="True ONLY if the object is made of metal, stainless steel, or aluminum."
)
class DishSafetyResult(BaseModel): class DishSafetyResult(BaseModel):
utensils_and_containers: list[UtensilMaterialCheck] = Field( utensil_or_foreign_object_detected: bool = Field(
description="List each visible non-food object and check if it is made of metal." 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="Short description of the materials present."
)
detected_hazards: list[str] = Field(
description="List objects from utensils_and_containers where is_metal is True."
) )
is_safe: bool = Field( is_safe: bool = Field(
description="True if detected_hazards is completely empty []. False if ANY metal is found." 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="If is_safe is False, set to 'REMOVE METAL UTENSIL OR FOIL BEFORE MICROWAVING'. Otherwise empty ''." 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 microwave dish image strictly for EQUIPMENT DAMAGE HAZARDS (Metal, Steel, Aluminum Foil).\n\n" "Inspect this top-down photo of a food dish inside a microwave.\n\n"
"GOAL:\n" "STRICT MICROWAVE RULES:\n"
"Identify if any metallic item (metal spoon, fork, knife, aluminum foil, wire) is inside the dish.\n\n" "1. Check if ANY utensil (spoon, fork, knife, or handle) is present in or on the bowl.\n"
"RULES:\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"
"1. Examine all utensils carefully, even in dim lighting or dark spots.\n" "3. `is_safe` can ONLY be True if the dish contains strictly food and nothing else."
"2. Spoons, forks, and knives are often STAINLESS STEEL / METAL. If a spoon or fork is visible, mark is_metal = True unless it is clearly bright colored plastic.\n"
"3. Ignore chemical or health concerns (plastic cancer risks are IRRELEVANT). Focus ONLY on spark/fire hazards (metal, foil).\n"
"4. If ANY utensil is metal: set is_metal=True, add it to detected_hazards, and set is_safe=False."
) )
response_raw = generate( response_raw = generate(
@@ -46,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
+16
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
+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)
+1 -1
View File
@@ -9,7 +9,7 @@ services:
- 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
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"
+26 -28
View File
@@ -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
@@ -26,7 +27,8 @@ def is_technician_mode_active() -> bool:
return TECHNICIAN_MODE 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."""
@@ -104,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.IDLE, 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."""
@@ -266,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():
""" """
@@ -322,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']}")
@@ -477,15 +474,14 @@ async 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)
buzzer.buzzer_siren(beatsNb=5)
# --- HARDWARE CONTROLLERS --- # --- HARDWARE CONTROLLERS ---
def _stop_hardware(microwave_id: str): def _stop_hardware(microwave_id: str):
@@ -641,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:
@@ -657,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()
@@ -672,7 +670,7 @@ 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']}")
@@ -693,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
@@ -818,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 > 1.2 cm to detect dish insertion # - Must be > 1.5 cm to detect dish insertion
# - Must be < 0.8 cm to detect dish removal # - Must be < 0.8 cm to detect dish removal
if dist > 1.2: if dist > 2.0:
consecutive_present += 1 consecutive_present += 1
consecutive_absent = 0 consecutive_absent = 0
elif dist < 0.8: elif dist < 1.0:
consecutive_absent += 1 consecutive_absent += 1
consecutive_present = 0 consecutive_present = 0
else: else:
# Dead-zone (1.2cm to 0.8cm) -> Noise buffer # Dead-zone (2.0cm to 1.0cm) -> Noise buffer
consecutive_present = 0 consecutive_present = 0
consecutive_absent = 0 consecutive_absent = 0
+4
View File
@@ -4,9 +4,13 @@ zeroconf>=0.131.0
fastapi>=0.100.0,<1.0.0 fastapi>=0.100.0,<1.0.0
uvicorn>=0.20.0,<1.0.0 uvicorn>=0.20.0,<1.0.0
python-multipart>=0.0.6,<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
# 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
+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)
+43 -22
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: try:
with grove_lock: for _ in range(beats_nb):
grovepi.digitalWrite(BUZZER_PIN, state) # 1. Turn sound ON (only acquire lock briefly for the I2C write)
with safe_grove_access(timeout=1.0) as acquired:
if acquired:
grovepi.analogWrite(BUZZER_PIN, 128)
time.sleep(delay) # Sleep WITHOUT holding the lock!
# 2. Turn sound OFF
with safe_grove_access(timeout=1.0) as acquired:
if acquired:
grovepi.analogWrite(BUZZER_PIN, 0)
time.sleep(delay) # Sleep WITHOUT holding the lock!
except Exception as e: except Exception as e:
print(f"[BUZZER] Error occurred while setting buzzer state: {e}") print(f"[BUZZER] Error during siren execution: {e}")
finally:
_stop_sound()
def buzzer_toggle(): def buzzer_siren(beats_nb: int = 3, delay: float = 0.2, async_run: bool = True):
global buzzer_state if async_run:
new_state = 1 - buzzer_state thread = threading.Thread(target=_siren_worker, args=(beats_nb, delay), daemon=True)
set_buzzer(new_state) thread.start()
else:
def buzzer_siren(beatsNb: int = 3, delay: float = 0.3): _siren_worker(beats_nb, delay)
for _ in range(beatsNb):
set_buzzer(1)
time.sleep(delay)
set_buzzer(0)
time.sleep(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()
+7 -3
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)."""
@@ -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
+26
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()
+10 -4
View File
@@ -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)
+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
+11 -20
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
+7 -3
View File
@@ -1,6 +1,10 @@
import time import time
import asyncio try:
import inspect import asyncio
import inspect
ASYNCIO = True
except ImportError:
ASYNCIO = False
class AlertManager: class AlertManager:
def __init__(self): def __init__(self):
@@ -12,7 +16,7 @@ class AlertManager:
def call_on_alert_callback(self, alert): def call_on_alert_callback(self, alert):
if self.on_alert_callback: if self.on_alert_callback:
if inspect.iscoroutinefunction(self.on_alert_callback): if ASYNCIO and inspect.iscoroutinefunction(self.on_alert_callback):
# Schedule coroutine on the running loop without blocking # Schedule coroutine on the running loop without blocking
try: try:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
+3
View File
@@ -1,7 +1,10 @@
# 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 # Technitian Web Server
TECHNICIAN_WEB_SERVER_HOST = "192.168.50.1" TECHNICIAN_WEB_SERVER_HOST = "192.168.50.1"
+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: