Compare commits

...

14 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
21 changed files with 220 additions and 130 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
) )
+2 -2
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
@@ -664,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))
+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)
+10 -10
View File
@@ -4,14 +4,14 @@ from APIs.aichat import generate
import shared.config as config import shared.config as config
class DishSafetyResult(BaseModel): class DishSafetyResult(BaseModel):
visible_objects: list[str] = Field( utensil_or_foreign_object_detected: bool = Field(
description="List EVERY distinct item visible in the frame, one item per list element (e.g., ['ceramic bowl', 'rice', 'metal spoon handle', 'stew'])." 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( is_safe: bool = Field(
description="Is the microwave dish safe to microwave? True if safe, False if unsafe." 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( detected_hazards: list[str] = Field(
description="List ONLY physical hazard items found. Empty list [] if safe." description="List any items found (e.g., ['spoon']). Empty list [] if safe."
) )
warning_message: str = Field( warning_message: str = Field(
description="Warning statement if unsafe, otherwise empty string ''." description="Warning statement if unsafe, otherwise empty string ''."
@@ -19,12 +19,11 @@ class DishSafetyResult(BaseModel):
def check_dish_safety(image_path: str) -> dict: def check_dish_safety(image_path: str) -> dict:
prompt = ( prompt = (
"You are an expert microwave safety quality inspector analyzing a top-down camera frame.\n\n" "Inspect this top-down photo of a food dish inside a microwave.\n\n"
"INSPECTION STEPS:\n" "STRICT MICROWAVE RULES:\n"
"Describe what you see in the image, identify any viewable hazards, and determine if the dish is safe to microwave.\n" "1. Check if ANY utensil (spoon, fork, knife, or handle) is present in or on the bowl.\n"
"Alert only if the cooking of the dish **will cause damage** to the microwave or the dish itself, like metallic objects.\n\n" "2. If ANY spoon or utensil is present (metal, plastic, or ceramic), you MUST set `utensil_or_foreign_object_detected = True` and `is_safe = False`.\n"
"NOTES:\n" "3. `is_safe` can ONLY be True if the dish contains strictly food and nothing else."
"The camera focus is wrongly setup, so the image may be blurry. Please do not take the blurriness or lack of view into a hazard"
) )
response_raw = generate( response_raw = generate(
@@ -33,6 +32,7 @@ def check_dish_safety(image_path: str) -> dict:
output_format=DishSafetyResult, output_format=DishSafetyResult,
should_think=False, should_think=False,
) )
if config.DEBUG_MESSAGES: if config.DEBUG_MESSAGES:
print(f"[Debug] Raw response from AI generator: {response_raw}") print(f"[Debug] Raw response from AI generator: {response_raw}")
+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
+6 -3
View File
@@ -205,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):
@@ -214,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
@@ -347,6 +347,8 @@ async def lora_process_task():
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 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")
@@ -356,7 +358,8 @@ async def lora_process_task():
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 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(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 [ -f "$REQUIREMENTS_FILE" ]; then if [ ! -d "$VENV_DIR" ]; then
echo "Vérification des dépendances Python..." echo "Création de l'environnement virtuel Python..."
if [ "$(id -u)" -eq 0 ]; then "$PYTHON_BIN" -m venv "$VENV_DIR"
"$PYTHON_BIN" -m pip install -r "$REQUIREMENTS_FILE"
else
"$PYTHON_BIN" -m pip install --user -r "$REQUIREMENTS_FILE"
fi 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 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 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"
+8 -6
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."""
@@ -113,7 +115,7 @@ def on_microwave_change(state: MicrowaveState, field: MicrowaveStateFields):
if state.cooking_state in (CookingStates.DONE, CookingStates.STIRRING_REQUIRED, CookingStates.ALERT): if state.cooking_state in (CookingStates.DONE, CookingStates.STIRRING_REQUIRED, CookingStates.ALERT):
# Trigger buzzer for user attention # Trigger buzzer for user attention
beats = 5 if state.cooking_state == CookingStates.ALERT else (1 if state.cooking_state == CookingStates.STIRRING_REQUIRED else 3) 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: elif state.cooking_state == CookingStates.DONE and state.state == MicrowaveState.COOKING:
state.set_state(MicrowaveState.DONE) state.set_state(MicrowaveState.DONE)
print(f"[{state.microwave_id}] Cooking finished. Waiting for user to remove dish.") 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 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():
""" """
@@ -478,7 +480,7 @@ async def on_new_alert(alert: Alert):
# Send the alert to the lora device # Send the alert to the lora device
payload = payloads.lora_new_alert(alert) payload = payloads.lora_new_alert(alert)
print(f"[Alert Handling] Sending alert to LoRa device: {payload}") print(f"[Alert Handling] Sending alert to LoRa device: {payload}")
if lora.send_reliable(payload): if lora.send_reliable(payload, max_retries=7):
microwave_states["2"].set_cooking_state(CookingStates.ALERT) microwave_states["2"].set_cooking_state(CookingStates.ALERT)
# --- HARDWARE CONTROLLERS --- # --- HARDWARE CONTROLLERS ---
+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
+5 -11
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,9 +10,11 @@ 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:
if not acquired:
return None return None
try: try:
val = grovepi.digitalRead(button) val = grovepi.digitalRead(button)
# Force strict binary output (0 or 1) # Force strict binary output (0 or 1)
@@ -20,18 +22,13 @@ def read_button_state():
except Exception as e: except Exception as e:
log(f"BTN Error: {e}") log(f"BTN Error: {e}")
return None return None
finally:
grove_lock.release()
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)
+42 -21
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
# Initialize pin mode safely
try:
with safe_grove_access(timeout=1.0) as acquired:
if acquired:
grovepi.pinMode(BUZZER_PIN, "OUTPUT") grovepi.pinMode(BUZZER_PIN, "OUTPUT")
buzzer_state = 0 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)
-3
View File
@@ -1,6 +1,3 @@
import grovepi
import math
from sensors.lock import grove_lock
from picamera2 import Picamera2, Preview from picamera2 import Picamera2, Preview
import time import time
+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
+5 -14
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
+1
View File
@@ -4,6 +4,7 @@ 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_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"
+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: