diff --git a/orchestrateur/launch.sh b/orchestrateur/launch.sh index 1bd7c6f..a25e0b4 100644 --- a/orchestrateur/launch.sh +++ b/orchestrateur/launch.sh @@ -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" \ No newline at end of file +cd "$SCRIPT_DIR" +exec "$VENV_PYTHON" "$PYTHON_SCRIPT" \ No newline at end of file diff --git a/orchestrateur/main.py b/orchestrateur/main.py index e9dea1b..006c100 100644 --- a/orchestrateur/main.py +++ b/orchestrateur/main.py @@ -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.""" diff --git a/orchestrateur/requirements.txt b/orchestrateur/requirements.txt index da48bfa..99bb136 100644 --- a/orchestrateur/requirements.txt +++ b/orchestrateur/requirements.txt @@ -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 \ No newline at end of file diff --git a/orchestrateur/sensors/button.py b/orchestrateur/sensors/button.py index 483b3d7..5a619da 100644 --- a/orchestrateur/sensors/button.py +++ b/orchestrateur/sensors/button.py @@ -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) diff --git a/orchestrateur/sensors/buzzer.py b/orchestrateur/sensors/buzzer.py index 78770c8..a6cb672 100644 --- a/orchestrateur/sensors/buzzer.py +++ b/orchestrateur/sensors/buzzer.py @@ -1,42 +1,51 @@ import time import threading import grovepi -from sensors.lock import grove_lock +from sensors.lock import safe_grove_access from shared import config BUZZER_PIN = 8 # Must be a PWM pin on GrovePi (D3, D5, D6, D8 support analogWrite) -# Initialize pin mode +# Initialize pin mode safely try: - with grove_lock: - grovepi.pinMode(BUZZER_PIN, "OUTPUT") + 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 _siren_worker(beats_nb: int, delay: float): - if not config.BUZZER_ACTIVATED: - return - - with grove_lock: - try: - for _ in range(beats_nb): - # 1. Send single PWM write to start sound (Duty cycle ~128/255) - grovepi.analogWrite(BUZZER_PIN, 128) - time.sleep(delay) - - # 2. Send single PWM write to kill sound - grovepi.analogWrite(BUZZER_PIN, 0) - time.sleep(delay) - except Exception as e: - print(f"[BUZZER] Error during siren execution: {e}") - finally: - # Absolute safety reset +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: + 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) diff --git a/orchestrateur/sensors/gps.py b/orchestrateur/sensors/gps.py index d6f58ce..581071d 100644 --- a/orchestrateur/sensors/gps.py +++ b/orchestrateur/sensors/gps.py @@ -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 \ No newline at end of file diff --git a/orchestrateur/sensors/lock.py b/orchestrateur/sensors/lock.py index e8b5f34..9e7f5af 100644 --- a/orchestrateur/sensors/lock.py +++ b/orchestrateur/sensors/lock.py @@ -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() \ No newline at end of file +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() \ No newline at end of file diff --git a/orchestrateur/sensors/rfid_reader.py b/orchestrateur/sensors/rfid_reader.py index 2cc035f..149de09 100644 --- a/orchestrateur/sensors/rfid_reader.py +++ b/orchestrateur/sensors/rfid_reader.py @@ -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) diff --git a/orchestrateur/sensors/rgb_lcd.py b/orchestrateur/sensors/rgb_lcd.py index 3524245..8553a8d 100644 --- a/orchestrateur/sensors/rgb_lcd.py +++ b/orchestrateur/sensors/rgb_lcd.py @@ -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 \ No newline at end of file diff --git a/orchestrateur/sensors/ultrasonicRanger.py b/orchestrateur/sensors/ultrasonicRanger.py index 2deabfb..5157307 100644 --- a/orchestrateur/sensors/ultrasonicRanger.py +++ b/orchestrateur/sensors/ultrasonicRanger.py @@ -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 \ No newline at end of file + return max(dish_height, 0) + return None \ No newline at end of file diff --git a/orchestrateur/smartwave.service b/orchestrateur/smartwave.service index 8c669f6..8005ad6 100644 --- a/orchestrateur/smartwave.service +++ b/orchestrateur/smartwave.service @@ -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 diff --git a/shared/config.py b/shared/config.py index 49712da..96e8377 100644 --- a/shared/config.py +++ b/shared/config.py @@ -4,6 +4,7 @@ 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"