Compare commits

..

30 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
22 changed files with 342 additions and 235 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
params = {
"app_id": EDAMAM_APP_ID__FOOD,
"app_key": EDAMAM_APP_KEY__FOOD,
"beta": "true" # Recommended since vision is in Beta
}
print("params:", params)
# params = {
# "app_id": EDAMAM_APP_ID__FOOD,
# "app_key": EDAMAM_APP_KEY__FOOD,
# "beta": "true" # Recommended since vision is in Beta
# }
# print("params:", params)
# Specify the media type in the header
headers = {
@@ -932,7 +932,7 @@ class EdamamAPI:
response = requests.post(
url,
params=params,
# params=params,
headers=headers,
data=raw_image_bytes
)
+88 -84
View File
@@ -220,7 +220,7 @@ async def cooking_params():
if not data or not isinstance(data, dict):
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')
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)
# 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(
microwave_cook_planner.generate_plan,
image_path=enhanced_filepath,
@@ -323,97 +323,101 @@ def alert():
alert_type = alert_info.get("type", "Unknown")
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
room_title = f"Support - {client_name}"
room_link = None
room_title = None
webex_status = "skipped"
sms_sent = False
three_minutes_ago = datetime.now(timezone.utc) - timedelta(minutes=3)
recent_alert = alert_collection.find_one({
"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 alert_type != "COOKING_SAFETY":
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
)
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 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}"
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_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)}"
room_title = f"Support - {client_name}"
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
)
three_minutes_ago = datetime.now(timezone.utc) - timedelta(minutes=3)
recent_alert = alert_collection.find_one({
"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)])
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
})
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}"
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
return jsonify({
@@ -601,7 +605,7 @@ def debug_unsafe_dish():
def debug_safe_dish():
"""Debug endpoint to simulate a safe dish scenario."""
# 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
return safety_checker.check_dish_safety(unsafe_dish)
@@ -660,4 +664,4 @@ def odata_metadata():
if __name__ == "__main__":
app.run(debug=getattr(config, "DEBUG", True))
app.run(debug=getattr(config, "DEBUG", False))
+17
View File
@@ -1,6 +1,12 @@
import cv2
import numpy as np
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:
@@ -42,6 +48,17 @@ class MicrowaveDishAnalyzer:
largest_contour = max(contours, key=cv2.contourArea)
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
area_cm2 = area_pixels * (self.cm_per_pixel ** 2)
return float(area_cm2)
+14 -11
View File
@@ -4,25 +4,26 @@ from APIs.aichat import generate
import shared.config as config
class DishSafetyResult(BaseModel):
utensil_or_foreign_object_detected: bool = Field(
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."
)
is_safe: bool = Field(
description="Set to True if the dish contains ONLY normal food, rice, stew, and ceramic/glass/plastic bowls. Set to False ONLY if shiny metallic cutlery (spoon/fork/knife) or metallic 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 ONLY physical metal objects found (e.g., ['metal spoon']). If is_safe is True, this MUST be an empty list []."
description="List any items found (e.g., ['spoon']). Empty list [] if safe."
)
warning_message: str = Field(
description="Clear warning if is_safe is False, otherwise empty string ''."
description="Warning statement if unsafe, otherwise empty string ''."
)
def check_dish_safety(image_path: str) -> dict:
prompt = (
"You are a strict microwave safety vision inspector.\n\n"
"TASK: Inspect the dish for ANY metal or plastic utensils (spoons, forks, knives, metal handles) or aluminum foil.\n\n"
"CRITICAL RULES:\n"
"1. Look closely at the edges and interior of the bowl for utensil handles sticking out, even if they appear dark or shadowed.\n"
"2. If you see ANY spoon handle, fork, knife, or utensil body—whether shiny silver, grey, or dark metal—mark is_safe = False.\n"
"3. ONLY mark is_safe = True if the bowl contains 100% food and rice with NO protruding utensils or foreign objects.\n"
"4. If a spoon or utensil is detected, set detected_hazards = ['metal spoon'] and warning_message = 'Please remove the metal utensil before microwaving.'"
"Inspect this top-down photo of a food dish inside a microwave.\n\n"
"STRICT MICROWAVE RULES:\n"
"1. Check if ANY utensil (spoon, fork, knife, or handle) is present in or on the bowl.\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"
"3. `is_safe` can ONLY be True if the dish contains strictly food and nothing else."
)
response_raw = generate(
@@ -31,7 +32,9 @@ def check_dish_safety(image_path: str) -> dict:
output_format=DishSafetyResult,
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):
try:
+17 -1
View File
@@ -1,3 +1,19 @@
`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
+20 -6
View File
@@ -49,19 +49,26 @@ PING_PAYLOAD = {
"type": deviceTypes.DEVICE_TYPES["MICROWAVE"]
}
last_cooking_update = {}
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."""
global last_cooking_update
if not lora:
return
send_reliably = False
payload = {
"id": DEVICE_ID,
"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
send_reliably = True
if estimated_remaining_time is not None:
payload["estimated_remaining_time"] = estimated_remaining_time
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
if len(payload) > 2:
log(f"[LoRa] Sending COOKING_UPDATE payload: {payload}")
lora.send(payload)
last_cooking_update = payload.copy()
if send_reliably:
lora.send_reliable(payload)
else:
lora.send(payload)
def on_new_alert(alert: Alert):
@@ -195,7 +205,7 @@ def cooking_state_temperature_provider():
return (0.0, 0.0)
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}")
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()
# 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}))
# Send LoRa update with start time and remaining time if beginning cooking process
@@ -336,6 +346,9 @@ async def lora_process_task():
if alert:
alert.timestamp = data.get("timestamp", time.time())
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:
new_state = data.get("new_microwave_state")
@@ -345,7 +358,8 @@ async def lora_process_task():
if new_state not in (MicrowaveState.ALERT): # Not alert so we can show the error
update_screen()
if new_state == MicrowaveState.IDLE:
cooking_state = None
if cooking_state is not None:
cooking_state.set_state(cookingState.CookingStates.IDLE)
await asyncio.sleep_ms(20)
await asyncio.sleep_ms(100)
+1 -1
View File
@@ -9,7 +9,7 @@ services:
- NET_ADMIN
environment:
- OT_RCP_DEVICE=spinel+hdlc+uart:///dev/ttyUSB1?uart-baudrate=460800
- OT_INFRA_IF=wlx6815790f3204
- OT_INFRA_IF=wlan1
- OT_THREAD_IF=wpan0
- OT_LOG_LEVEL=6
- FIREWALL=0
+17 -12
View File
@@ -3,6 +3,7 @@ set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
REPO_ROOT=$(dirname -- "$SCRIPT_DIR")
VENV_DIR="$SCRIPT_DIR/venv"
PYTHON_BIN="${PYTHON_BIN:-python3}"
PYTHON_SCRIPT="${1:-$SCRIPT_DIR/main.py}"
REQUIREMENTS_FILE="$SCRIPT_DIR/requirements.txt"
@@ -19,24 +20,28 @@ cd "$SCRIPT_DIR"
# 2. On lance Docker en arrière-plan
echo "Démarrage des conteneurs Docker..."
# docker compose pull
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
echo "Vérification des dépendances Python..."
if [ "$(id -u)" -eq 0 ]; then
"$PYTHON_BIN" -m pip install -r "$REQUIREMENTS_FILE"
else
"$PYTHON_BIN" -m pip install --user -r "$REQUIREMENTS_FILE"
fi
echo "Vérification et installation des dépendances dans le venv..."
"$VENV_PIP" install --upgrade pip >/dev/null 2>&1 || true
"$VENV_PIP" install -r "$REQUIREMENTS_FILE"
fi
# 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"
if [ -e "$PORT" ]; then
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
echo "Le port série est occupé ! Libération en cours..."
fuser -k "$PORT" || true
@@ -44,7 +49,7 @@ if [ -e "$PORT" ]; then
fi
fi
# 5. Lancement propre du script Python
# 5. Lancement propre du script Python via le venv
echo "Launching Python script: $PYTHON_SCRIPT"
cd "$REPO_ROOT"
exec "$PYTHON_BIN" "$PYTHON_SCRIPT"
cd "$SCRIPT_DIR"
exec "$VENV_PYTHON" "$PYTHON_SCRIPT"
+16 -15
View File
@@ -3,6 +3,7 @@ import json
import time
import asyncio
import requests
import os
from shared.microwave_state import MicrowaveState, MicrowaveStateFields
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
# --- 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():
"""Ensures the connected_components table exists on startup."""
@@ -104,7 +106,7 @@ 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
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
lora.send_reliable(payloads.lora_microwave_state(state.state), max_retries=5)
@@ -113,7 +115,7 @@ def on_microwave_change(state: MicrowaveState, field: MicrowaveStateFields):
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(beatsNb=beats)
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.")
@@ -275,17 +277,17 @@ async def rfid_listener_task():
TECHNICIAN_MODE = True
print(f"[RFID] Card {tag_id} VALIDATED! -> TECHNICIAN_MODE = TRUE")
# Optional: Sound positive feedback beep
buzzer.buzzer_siren(beatsNb=1)
buzzer.buzzer_siren(beats_nb=2)
else:
print(f"[RFID] Card {tag_id} REJECTED / Invalid.")
# Optional: Sound error feedback beep
buzzer.buzzer_siren(beatsNb=2)
buzzer.buzzer_siren(beats_nb=4)
except Exception as e:
print(f"[RFID Task] Error reading RFID tag: {e}")
# Sleep 100ms between checks to keep CPU usage near zero
await asyncio.sleep(0.1)
await asyncio.sleep(0.2)
async def display_broadcast_worker_task():
"""
@@ -472,15 +474,14 @@ async def on_new_alert(alert: Alert):
response.raise_for_status()
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
# Send the alert to the lora device
payload = payloads.lora_new_alert(alert)
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)
buzzer.buzzer_siren(beatsNb=5)
# --- HARDWARE CONTROLLERS ---
def _stop_hardware(microwave_id: str):
@@ -669,7 +670,7 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
microwave_states[microwave_id].set_state(MicrowaveState.DONE)
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)
else:
alert = Alert(AlertType.ERROR, f"Cloud API error for microwave {microwave_id}: {res_json['error']}")
@@ -690,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")
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_temp = 50 # Set to 50°C for debug
@@ -815,16 +816,16 @@ async def monitor_dish_height_task():
if dist is not None:
# 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
if dist > 1.2:
if dist > 2.0:
consecutive_present += 1
consecutive_absent = 0
elif dist < 0.8:
elif dist < 1.0:
consecutive_absent += 1
consecutive_present = 0
else:
# Dead-zone (1.2cm to 0.8cm) -> Noise buffer
# Dead-zone (2.0cm to 1.0cm) -> Noise buffer
consecutive_present = 0
consecutive_absent = 0
+4
View File
@@ -4,9 +4,13 @@ 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
# OpenCV
# sudo apt install -y python3-opencv
# sudo apt install -y opencv-data
# sudo apt install -y swig python3-dev liblgpio-dev
# ... Replaces this ?
# sudo apt install pigpio python3-pigpio
# sudo systemctl enable pigpiod --now
+13 -19
View File
@@ -1,7 +1,7 @@
import grovepi
import time
import threading
from sensors.lock import grove_lock
from sensors.lock import safe_grove_access
from shared.logging import log
button = 2
@@ -10,28 +10,25 @@ grovepi.pinMode(button, "INPUT")
button_callback = None
def read_button_state():
# Increase timeout slightly so the button thread can wait for long I2C sensor reads to finish
if not grove_lock.acquire(timeout=0.2):
return None
try:
val = grovepi.digitalRead(button)
# Force strict binary output (0 or 1)
return 1 if val == 1 else 0
except Exception as e:
log(f"BTN Error: {e}")
return None
finally:
grove_lock.release()
# Attempt to acquire the lock with a 0.2s timeout
with safe_grove_access(timeout=0.2) as acquired:
if not acquired:
return None
try:
val = grovepi.digitalRead(button)
# Force strict binary output (0 or 1)
return 1 if val == 1 else 0
except Exception as e:
log(f"BTN Error: {e}")
return None
def monitor_button():
last_stable_state = 0
candidate_state = 0
consecutive_count = 0
# Require 3 consecutive identical reads (~60ms) to confirm a valid state change
REQUIRED_CONSECUTIVE_READS = 3
# Minimum time gap (in seconds) between allowed button triggers (cooldown)
DEBOUNCE_COOLDOWN = 0.3
last_trigger_time = 0
@@ -45,11 +42,9 @@ def monitor_button():
candidate_state = current_state
consecutive_count = 1
# State is confirmed stable across multiple reads
if consecutive_count >= REQUIRED_CONSECUTIVE_READS:
now = time.time()
# Rising edge detection (0 -> 1 transition) with cooldown timer
if candidate_state == 1 and last_stable_state == 0:
if (now - last_trigger_time) > DEBOUNCE_COOLDOWN:
last_trigger_time = now
@@ -63,7 +58,6 @@ def monitor_button():
time.sleep(0.02) # 20ms poll interval
else:
# Lock was busy or read failed; reset candidate counter to reject noisy spikes
consecutive_count = 0
time.sleep(0.01)
+45 -24
View File
@@ -1,33 +1,54 @@
import time
import threading
import grovepi
from sensors.lock import grove_lock
from sensors.lock import safe_grove_access
from shared import config
# Pin definition
BUZZER_PIN = 8
BUZZER_PIN = 8 # Must be a PWM pin on GrovePi (D3, D5, D6, D8 support analogWrite)
grovepi.pinMode(BUZZER_PIN, "OUTPUT")
buzzer_state = 0
# Initialize pin mode safely
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):
global buzzer_state
buzzer_state = state
def _stop_sound():
"""Helper to ensure sound is silenced safely under lock."""
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:
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():
global buzzer_state
new_state = 1 - buzzer_state
set_buzzer(new_state)
def buzzer_siren(beatsNb: int = 3, delay: float = 0.3):
for _ in range(beatsNb):
set_buzzer(1)
time.sleep(delay)
set_buzzer(0)
time.sleep(delay)
try:
for _ in range(beats_nb):
# 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:
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)
+6 -11
View File
@@ -1,6 +1,3 @@
import grovepi
import math
from sensors.lock import grove_lock
from picamera2 import Picamera2, Preview
import time
@@ -16,14 +13,12 @@ camera_config = picam2.create_still_configuration(
main={"size": (1640, 1232)}
)
picam2.set_controls({
"AeEnable": False, # Disable Auto Exposure to lock settings
"ExposureTime": 60000, # 40 ms shutter time (brightens without grain)
"AnalogueGain": 2.0, # Keep gain low to eliminate sensor noise
"AwbEnable": False, # Disable Auto White Balance fluctuation
"ColourGains": (1.4, 1.2), # Balanced RGB gains for indoor warm LED
"Brightness": 0.2, # Slight boost to shadow detail
"Contrast": 1.1, # Crisp edges around metallic surfaces
"ScalerCrop": [0, 0, max_width, max_height] # Uncrop the image
"AeEnable": True,
"AwbEnable": True,
"ExposureTime": 100000,
"ExposureValue": 0.3,
"Brightness": 0.30,
"Contrast": 1.1
})
picam2.configure(camera_config)
+7 -3
View File
@@ -2,7 +2,7 @@ import serial
import time
import threading
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:
"""Validates standard NMEA 0183 sentence checksum ($...*HH)."""
@@ -35,7 +35,12 @@ class GROVEGPS:
def read(self):
"""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
self.ser.reset_input_buffer()
@@ -115,5 +120,4 @@ def get_gps_data():
else:
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
+27 -1
View File
@@ -1,5 +1,31 @@
import threading
from contextlib import contextmanager
# Dedicated lock for I2C bus access (used by GrovePi sensors)
grove_lock = threading.Lock()
# 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 pigpio
from typing import Optional
from sensors.lock import serial_lock
from sensors.lock import safe_serial_access
class RFIDReader:
@@ -19,7 +19,10 @@ class RFIDReader:
if not self.pi.connected:
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)
# Clean up lingering serial sessions on this pin
@@ -34,7 +37,10 @@ class RFIDReader:
"""
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:
return None
@@ -70,7 +76,7 @@ class RFIDReader:
return None
def close(self):
with serial_lock:
with safe_serial_access(timeout=1.0) as acquired:
if self.pi and self.pi.connected:
try:
self.pi.bb_serial_read_close(self.rx_pin)
+12 -10
View File
@@ -1,6 +1,6 @@
import time
import sys
from sensors.lock import grove_lock
from sensors.lock import safe_grove_access
if sys.platform == 'uwp':
import winrt_smbus as smbus
@@ -18,7 +18,6 @@ else:
DISPLAY_RGB_ADDR = 0x62
DISPLAY_TEXT_ADDR = 0x3e
def setRGB(r, g, b, brightness=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)))
@@ -27,7 +26,9 @@ def setRGB(r, g, b, brightness=1.0):
g_scaled = int(max(0, min(255, g * 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:
bus.write_byte_data(DISPLAY_RGB_ADDR, 0, 0)
bus.write_byte_data(DISPLAY_RGB_ADDR, 1, 0)
@@ -38,15 +39,15 @@ def setRGB(r, g, b, brightness=1.0):
except OSError:
pass
def textCommand(cmd):
"""Send command to display (internal use)."""
bus.write_byte_data(DISPLAY_TEXT_ADDR, 0x80, cmd)
def setText(text):
"""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:
textCommand(0x01) # Clear display
time.sleep(0.05)
@@ -67,14 +68,15 @@ def setText(text):
continue
count += 1
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:
pass
def setText_norefresh(text):
"""Update display text without full screen erase."""
with grove_lock:
with safe_grove_access(timeout=1.5) as acquired:
if not acquired:
return
try:
textCommand(0x02) # Return home
time.sleep(0.05)
@@ -97,6 +99,6 @@ def setText_norefresh(text):
continue
count += 1
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:
pass
+13 -22
View File
@@ -1,33 +1,24 @@
import grovepi
from sensors.lock import grove_lock
from sensors.lock import safe_grove_access
from shared import config
# Connect the Grove Ultrasonic Ranger to digital port D4
# SIG,NC,VCC,GND
ULTRASONIC_RANGER_PORT = 4
def read_ultrasonic_ranger(ultrasonic_ranger=ULTRASONIC_RANGER_PORT):
if not grove_lock.acquire(timeout=1.0):
print("Ultrasonic: Lock acquisition timed out")
return None
try:
return grovepi.ultrasonicRead(ultrasonic_ranger)
except Exception as e:
print(f"Error: {e}")
return None
finally:
grove_lock.release()
with safe_grove_access(timeout=1.5) as acquired:
if not acquired:
return None
try:
return grovepi.ultrasonicRead(ultrasonic_ranger)
except Exception as e:
print(f"Ultrasonic read error: {e}")
return None
def get_dish_height():
"""Returns the height of the dish in centimeters."""
distance = read_ultrasonic_ranger()
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
return max(dish_height, 0) # Ensure height is not negative
else:
return None
return max(dish_height, 0)
return None
+1
View File
@@ -9,6 +9,7 @@ User=pi
Group=pi
WorkingDirectory=/home/pi/SmartWave
EnvironmentFile=-/etc/default/smartwave
Environment="PYTHONUNBUFFERED=1"
ExecStart=/bin/sh /home/pi/SmartWave/orchestrateur/launch.sh /home/pi/SmartWave/orchestrateur/main.py
Restart=on-failure
RestartSec=5
+3
View File
@@ -1,7 +1,10 @@
# Debugging and others
DEBUG=True
DEBUG_MESSAGES=True
DEBUG_DANGEROUS_AREA=False
DEBUG_TEMPERATURE_ALERT=False
DEBUG_SHORTER_COOKING_PLAN=True
DEBUG_DISHANALYZER=True
# Technitian Web Server
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):
"""Log a message to the console if DEBUG is enabled."""
if DEBUG:
if DEBUG_MESSAGES:
print(f"\n{message}")
+1 -1
View File
@@ -81,7 +81,7 @@ class BaseLoraDevice:
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."""
with self.tx_lock: