Compare commits

...

18 Commits

Author SHA1 Message Date
Ninluc 5c60017e8d Wait, is this peak ?
Build, push image, and notify Watchtower / build-image (push) Successful in 3m32s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-04 18:21:20 +02:00
Ninluc caf81d4bbb Asyncio refactor for wifi and rpi 2026-08-04 17:13:01 +02:00
Ninluc 43a1822547 Working MQTT back ! 2026-08-03 16:41:48 +02:00
Ninluc 9eac93c409 Simplified Uart comunications 2026-08-01 15:45:58 +02:00
Ninluc 7299a50198 Beginning of cooking cycle
Build, push image, and notify Watchtower / build-image (push) Successful in 1m21s
Build, push image, and notify Watchtower / notify (push) Successful in 17s
2026-07-31 21:50:42 +02:00
Ninluc 8ac5db22c1 Moved rgb led to shared 2026-07-31 16:24:41 +02:00
Ninluc b12296bf0e Added RGB led control class 2026-07-30 17:39:29 +02:00
Ninluc 9e078490dd Added needed watts
Build, push image, and notify Watchtower / build-image (push) Successful in 58s
Build, push image, and notify Watchtower / notify (push) Successful in 15s
2026-07-27 18:10:15 +02:00
Ninluc 059bb75555 Ignore orchestrator Database 2026-07-27 18:09:52 +02:00
Ninluc 181009604d Removed unused print
Build, push image, and notify Watchtower / build-image (push) Successful in 59s
Build, push image, and notify Watchtower / notify (push) Successful in 12s
2026-07-27 17:31:52 +02:00
Ninluc d6290efb18 Longer timeout 2026-07-27 17:31:44 +02:00
Ninluc 2c66a24e9d Fix dockerfile ?
Build, push image, and notify Watchtower / build-image (push) Successful in 3m16s
Build, push image, and notify Watchtower / notify (push) Successful in 14s
2026-07-27 17:24:11 +02:00
Ninluc 0f17e9dce6 Better cook parameter estimation + Defrost mode + Removed esp-wifi debugs + Orchestrator and microwave exchange
Build, push image, and notify Watchtower / build-image (push) Successful in 3m19s
Build, push image, and notify Watchtower / notify (push) Successful in 7s
2026-07-27 17:14:05 +02:00
Ninluc 81de985580 Answer with cooking plan and save edamam API tokens
Build, push image, and notify Watchtower / build-image (push) Successful in 3m59s
Build, push image, and notify Watchtower / notify (push) Successful in 7s
2026-07-27 14:37:05 +02:00
Ninluc 6a42e4a772 Edamam API and dish volume estimation
Build, push image, and notify Watchtower / build-image (push) Successful in 2m33s
Build, push image, and notify Watchtower / notify (push) Successful in 9s
2026-07-25 16:31:50 +02:00
Ninluc a0af426c78 Temperature Sensor
Build, push image, and notify Watchtower / build-image (push) Successful in 45s
Build, push image, and notify Watchtower / notify (push) Successful in 10s
2026-07-25 16:31:23 +02:00
Ninluc 5a737c931c Debug with global config variable
Build, push image, and notify Watchtower / build-image (push) Successful in 37s
Build, push image, and notify Watchtower / notify (push) Successful in 8s
2026-07-23 17:06:17 +02:00
Ninluc 181a395b4d Removed debug dependencies 2026-07-23 17:06:03 +02:00
38 changed files with 4063 additions and 1464 deletions
+11
View File
@@ -0,0 +1,11 @@
.venv
venv
ENV
env
.env
__pycache__
*.pyc
*.pyo
*.pyd
.git
.gitignore
+2
View File
@@ -0,0 +1,2 @@
from APIs.aichat import *
from APIs.edamam import *
+1834
View File
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -1,24 +1,24 @@
FROM python:3.11-slim FROM python:3.11-slim
# Set the working directory inside the container # Prevent Python from writing .pyc files and buffer stdout/stderr
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV PYTHONPATH=/cloud
WORKDIR /cloud WORKDIR /cloud
# DEBUG # Copy requirements from build context root or relative path
RUN apt update && apt install -y dnsutils iputils-ping
# Copy the requirements file and install dependencies
COPY cloud/requirements.txt . COPY cloud/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Copy the rest of the application code # Copy application source code
COPY cloud/ . COPY cloud/ .
COPY shared/ ./shared/ COPY shared/ ./shared/
# Ensure the photo storage directory exists so the app doesn't crash on startup # Create photo storage directory
RUN mkdir -p storage/dishPhotos RUN mkdir -p storage/dishPhotos
# Expose the port the app will run on
EXPOSE 5000 EXPOSE 5000
# Use Gunicorn to run the application in production # Call gunicorn directly
CMD ["python", "-m", "gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--threads", "4", "--timeout", "300", "app:app"] CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--threads", "4", "--timeout", "300", "app:app"]
+49 -16
View File
@@ -3,7 +3,14 @@ import base64
import uuid import uuid
from flask import Flask, request, jsonify from flask import Flask, request, jsonify
from pymongo import MongoClient from pymongo import MongoClient
from tools.aichat import generate from APIs import generate, EdamamAPI
import sys
from microwaveCookPlanner import MicrowaveCookPlanner
sys.path.insert(0, '..')
try:
from shared import config
except ImportError:
from ..shared import config
app = Flask(__name__) app = Flask(__name__)
@@ -22,6 +29,11 @@ device_network_collection = db["device_network"]
CAMERA_IMAGE_DIR = "storage/dishCameraImages" CAMERA_IMAGE_DIR = "storage/dishCameraImages"
os.makedirs(CAMERA_IMAGE_DIR, exist_ok=True) os.makedirs(CAMERA_IMAGE_DIR, exist_ok=True)
# ---------------------------------------------------------
# Classes
# ---------------------------------------------------------
microwave_cook_planner = MicrowaveCookPlanner()
# --------------------------------------------------------- # ---------------------------------------------------------
# Routes # Routes
# --------------------------------------------------------- # ---------------------------------------------------------
@@ -40,40 +52,56 @@ def cooking_params():
if not data: if not data:
return jsonify({"error": "Invalid or missing JSON payload"}), 400 return jsonify({"error": "Invalid or missing JSON payload"}), 400
# Extract user or device parameters (with fallback defaults)
height_cm = float(data.get("dish_height", 4.0))
initial_temp_c = float(data.get("ir_initial_temp", 20.0)) # e.g., 4.0 for fridge, -18.0 for freezer
microwave_wattage = int(data.get("microwave_wattage", 900)) # e.g., 900W
defrost_mode = bool(data.get("defrost_mode", False)) # True for defrost, False for cook/reheat
print("Parsed parameters - Height (cm):", height_cm, "Initial Temp (C):", initial_temp_c, "Microwave Wattage:", microwave_wattage, "Defrost Mode:", defrost_mode)
# 1. Handle the Camera Image # 1. Handle the Camera Image
camera_image_b64 = data.get("camera_image") camera_image_b64 = data.get("camera_image")
filepath = None
if camera_image_b64: if camera_image_b64:
# Generate a unique filename using UUID to avoid overwriting
filename = f"dish_{uuid.uuid4().hex}.jpg" filename = f"dish_{uuid.uuid4().hex}.jpg"
filepath = os.path.join(CAMERA_IMAGE_DIR, filename) filepath = os.path.join(CAMERA_IMAGE_DIR, filename)
try: try:
# Decode the base64 string and save it as a binary file
with open(filepath, "wb") as f: with open(filepath, "wb") as f:
f.write(base64.b64decode(camera_image_b64)) f.write(base64.b64decode(camera_image_b64))
# Replace the giant base64 string in the dictionary with the local file path
# so we don't bloat the MongoDB document
data["camera_image"] = filepath data["camera_image"] = filepath
except Exception as e: except Exception as e:
return jsonify({"error": f"Failed to save camera image: {str(e)}"}), 500 return jsonify({"error": f"Failed to save camera image: {str(e)}"}), 500
else:
return jsonify({"error": "Missing required field 'camera_image'"}), 400
# 2. Save to MongoDB # 2. Run the Cook Planning Engine
try:
cook_plan = microwave_cook_planner.generate_plan(
image_path=filepath,
height_cm=height_cm,
initial_temp_c=initial_temp_c,
microwave_wattage=microwave_wattage,
defrost_mode=defrost_mode
)
except Exception as e:
return jsonify({"error": f"Failed to compute cooking plan: {str(e)}"}), 500
# 3. Attach cooking parameters to database record
data["analysis_results"] = cook_plan
# 4. Save to MongoDB
try: try:
# Insert the dictionary directly into Mongo (it will retain your exact JSON keys)
cooking_collection.insert_one(data) cooking_collection.insert_one(data)
# Remove the Mongo-injected '_id' object before returning the response
data.pop("_id", None) data.pop("_id", None)
return jsonify({"message": "Cooking parameters saved successfully", "data": data}), 201
except Exception as e: except Exception as e:
return jsonify({"error": f"Database error: {str(e)}"}), 500 return jsonify({"error": f"Database error: {str(e)}"}), 500
# 3. Returns with the cooking parameters # 5. Return complete output
return jsonify(cook_plan), 201
@app.route("/device-network", methods=["POST"]) @app.route("/device-network", methods=["POST"])
def device_network(): def device_network():
@@ -92,7 +120,12 @@ def device_network():
except Exception as e: except Exception as e:
return jsonify({"error": f"Database error: {str(e)}"}), 500 return jsonify({"error": f"Database error: {str(e)}"}), 500
@app.route("/debug", methods=["GET"])
def debug():
image_path = "microwaveDish.jpg"
edamam = EdamamAPI()
return edamam.analyze_dish_image(image_path)
if __name__ == "__main__": if __name__ == "__main__":
app.run(debug=True) app.run(debug=config.DEBUG)
+100
View File
@@ -0,0 +1,100 @@
from typing import Dict, Any
from APIs.edamam import EdamamAPI
from microwaveDishAnalyzer import MicrowaveDishAnalyzer
from microwaveThermalEngine import MicrowaveThermalEngine, DishThermalState
class MicrowaveCookPlanner:
"""Orchestrates Edamam API, Dish Analyzer, and Thermal Engine into a single workflow."""
def __init__(self, cm_per_pixel: float = 0.05):
self.edamam_api = EdamamAPI()
self.analyzer = MicrowaveDishAnalyzer(cm_per_pixel=cm_per_pixel)
self.engine = MicrowaveThermalEngine()
def _extract_edamam_data(self, edamam_resp: Dict[str, Any]) -> tuple[str, float, Dict[str, float]]:
"""Parses Edamam Vision response to extract label, total mass, and macronutrient grams."""
recipe = edamam_resp.get("combined", {}).get("recipe", {})
# Fallback to first dish if 'combined' is empty
if not recipe and edamam_resp.get("dishes"):
recipe = edamam_resp["dishes"][0].get("recipe", {})
label = recipe.get("label", "Unknown Dish")
total_weight = float(recipe.get("totalWeight", 300.0)) # Default 300g fallback
nutrients = recipe.get("totalNutrients", {})
# Extract macronutrients in grams (Edamam nutrient codes)
fat_g = float(nutrients.get("FAT", {}).get("quantity", 0.0))
protein_g = float(nutrients.get("PROCNT", {}).get("quantity", 0.0))
carbs_g = float(nutrients.get("CHOCDF", {}).get("quantity", 0.0))
# Water is sometimes omitted in Edamam; infer remaining mass as water if missing
if "WATER" in nutrients:
water_g = float(nutrients["WATER"].get("quantity", 0.0))
else:
water_g = max(0.0, total_weight - (fat_g + protein_g + carbs_g))
macros = {
"water_g": water_g,
"fat_g": fat_g,
"protein_g": protein_g,
"carbs_g": carbs_g,
}
return label, total_weight, macros
def generate_plan(
self,
image_path: str,
height_cm: float,
initial_temp_c: float,
microwave_wattage: int = 900,
defrost_mode: bool = False
) -> Dict[str, Any]:
"""Main pipeline call to parse an image and return cooking parameters."""
# 1. Vision & Nutrient Analysis
edamam_resp = self.edamam_api.analyze_dish_image(image_path)
food_label, edamam_mass_g, macros = self._extract_edamam_data(edamam_resp)
# 2. Geometric Volume Calculation
vol_data = self.analyzer.estimate_volume(
image_path=image_path,
height_cm=height_cm,
food_label=food_label
)
# 3. Mass Cross-Validation & Density Check
mass_data = self.analyzer.reconcile_mass(
edamam_mass_g=edamam_mass_g,
volume_cm3=vol_data["volume_cm3"],
food_label=food_label
)
final_mass_g = mass_data["final_mass_g"]
# 4. Thermal State Creation
thermal_state = DishThermalState(
food_name=food_label,
macronutrients=macros,
estimated_mass_g=final_mass_g,
initial_temp_c=initial_temp_c,
volume_cm3=vol_data["volume_cm3"]
)
# 5. Cook Plan Calculation
cook_plan = self.engine.calculate_cook_plan(
state=thermal_state,
microwave_wattage=microwave_wattage,
defrost_mode=defrost_mode
)
# Return consolidated output
return {
"dish_name": food_label,
"reconciled_mass_g": final_mass_g,
"mass_validation_status": mass_data["status"],
"cook_plan": cook_plan,
"geometry": vol_data
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

+129
View File
@@ -0,0 +1,129 @@
import cv2
import numpy as np
from typing import Dict, Any, Optional
class MicrowaveDishAnalyzer:
"""
Estimates food dish volume from top-down camera images and dish height,
and cross-validates physical volume against Edamam AI mass estimates.
"""
# Constant scale ratio: Centimeters per Pixel.
# TODO : Replace this value once your camera calibration is complete.
CM_PER_PIXEL: float = 0.05 # Example: 1 pixel = 0.05 cm
def __init__(self, cm_per_pixel: Optional[float] = None):
if cm_per_pixel is not None:
self.cm_per_pixel = cm_per_pixel
else:
self.cm_per_pixel = self.CM_PER_PIXEL
def calculate_surface_area_cm2(self, image_path: str) -> float:
"""
Segments the food/dish from the background and returns surface area in cm².
"""
image = cv2.imread(image_path)
if image is None:
raise FileNotFoundError(f"Image could not be loaded from path: {image_path}")
# 1. Convert to grayscale & blur to reduce noise
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
# 2. Otsu thresholding to segment foreground (dish) from background (turntable)
_, thresh = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
# 3. Find contours
contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return 0.0
# 4. Assume the largest contour corresponds to the dish/food area
largest_contour = max(contours, key=cv2.contourArea)
area_pixels = cv2.contourArea(largest_contour)
# 5. Convert pixels² to cm² using scale ratio squared
area_cm2 = area_pixels * (self.cm_per_pixel ** 2)
return float(area_cm2)
@staticmethod
def _get_shape_factor(food_label: str) -> float:
"""
Selects geometric correction factor (k_shape) based on container/food shape:
- Bowls/Soups: ~0.60 (paraboloid)
- Drinks/Mugs: ~0.95 (cylinder)
- Flat plates/solid foods: ~0.85 (truncated cone / disk)
"""
label = food_label.lower()
if any(w in label for w in ["soup", "chili", "stew", "curry", "bowl"]):
return 0.60
elif any(w in label for w in ["coffee", "tea", "milk", "water", "beverage", "mug"]):
return 0.95
elif any(w in label for w in ["bread", "cake", "muffin"]):
return 0.80
return 0.85 # Default factor for plated meals
def estimate_volume(
self, image_path: str, height_cm: float, food_label: str = ""
) -> Dict[str, float]:
"""
Computes total physical volume in cm³ (mL).
Volume = Area (cm²) * Height (cm) * Shape Factor
"""
area_cm2 = self.calculate_surface_area_cm2(image_path)
k_shape = self._get_shape_factor(food_label)
volume_cm3 = area_cm2 * height_cm * k_shape
return {
"surface_area_cm2": round(area_cm2, 2),
"height_cm": round(height_cm, 2),
"shape_factor": k_shape,
"volume_cm3": round(volume_cm3, 2),
}
def reconcile_mass(
self, edamam_mass_g: float, volume_cm3: float, food_label: str = ""
) -> Dict[str, Any]:
"""
Cross-validates Edamam's visual mass against physical volume using expected density.
Returns the most physically accurate mass estimate in grams.
"""
if volume_cm3 <= 0:
return {
"final_mass_g": edamam_mass_g,
"status": "unvalidated_zero_volume",
"calculated_density": None,
}
calculated_density = edamam_mass_g / volume_cm3
label = food_label.lower()
# Expected food densities (g/cm³)
if any(w in label for w in ["bread", "popcorn", "cake"]):
expected_density = 0.35
elif any(w in label for w in ["soup", "beverage", "water", "milk"]):
expected_density = 1.0
else:
expected_density = 0.92 # Average cooked meal (water + fats + carbs)
# Plausibility bounds (±35% variance around expected density)
min_density = expected_density * 0.65
max_density = expected_density * 1.35
if min_density <= calculated_density <= max_density:
# Edamam estimate is physically realistic
final_mass = edamam_mass_g
status = "validated_edamam_mass"
else:
# Edamam misjudged scale — fallback to Volume * Expected Density
final_mass = volume_cm3 * expected_density
status = "reconciled_via_volume_density"
return {
"final_mass_g": round(final_mass, 2),
"raw_edamam_mass_g": edamam_mass_g,
"calculated_density_g_cm3": round(calculated_density, 3),
"expected_density_g_cm3": expected_density,
"status": status,
}
+84
View File
@@ -0,0 +1,84 @@
from dataclasses import dataclass
from typing import Dict, Any, Optional
@dataclass
class DishThermalState:
food_name: str
macronutrients: Dict[str, float]
estimated_mass_g: float
initial_temp_c: float
volume_cm3: Optional[float] = None
class MicrowaveThermalEngine:
"""Calculates cook parameters based on physical properties"""
DEFAULT_EFFICIENCY = 0.70 # ~70% magnetron efficiency
COOK_TARGET_TEMP_C = 74.0 # Safe food temp for cooking/reheating
DEFROST_TARGET_TEMP_C = 4.0 # Chilled state target for defrosting
LATENT_HEAT_ICE_J_G = 334.0 # Joules required to melt 1g of ice to water
@staticmethod
def estimate_specific_heat(macros: Dict[str, float], total_weight_g: float) -> float:
"""Estimates Cp in J/(g*C) based on macro composition"""
if total_weight_g <= 0:
return 3.5
w_water = macros.get("water_g", total_weight_g * 0.7) / total_weight_g
w_protein = macros.get("protein_g", 0.0) / total_weight_g
w_fat = macros.get("fat_g", 0.0) / total_weight_g
w_carbs = macros.get("carbs_g", 0.0) / total_weight_g
return (4.184 * w_water) + (1.71 * w_protein) + (1.67 * w_fat) + (1.42 * w_carbs)
def calculate_cook_plan(
self, state: DishThermalState, microwave_wattage: int, defrost_mode: bool
) -> Dict[str, Any]:
cp = self.estimate_specific_heat(state.macronutrients, state.estimated_mass_g)
label = state.food_name.lower()
# Set target temperature based on selected mode
target_temp = self.DEFROST_TARGET_TEMP_C if defrost_mode else self.COOK_TARGET_TEMP_C
delta_t = max(0.0, target_temp - state.initial_temp_c)
# 1. Base thermal energy: Q_sensible = m * c_p * delta_t
required_joules = state.estimated_mass_g * cp * delta_t
# 2. Account for Phase Change (Ice -> Water) if food starts below 0°C
if state.initial_temp_c < 0:
water_g = state.macronutrients.get("water_g", state.estimated_mass_g * 0.7)
latent_energy_joules = water_g * self.LATENT_HEAT_ICE_J_G
required_joules += latent_energy_joules
# 3. Determine power level and duty cycle based on mode
if defrost_mode:
# Defrost mode strictly runs low power (30%) to allow heat conduction
power_level = 30 if "bread" in label or "baked" in label else 40
time_factor = 1.1 # Slight padding for thermal conductivity losses
else:
# Cook / Reheat Mode logic
if state.initial_temp_c < 0:
# Cooking from frozen needs lower power to defrost first, then cook
power_level = 50
time_factor = 1.35
elif state.estimated_mass_g > 350 and not any(w in label for w in ["soup", "beverage", "water", "tea"]):
power_level = 70
time_factor = 1.2
elif any(w in label for w in ["cheese", "cream", "sauce", "butter", "egg"]):
power_level = 60
time_factor = 1.25
else:
power_level = 100
time_factor = 1.0
# Effective power delivered to food
effective_power_watts = microwave_wattage * self.DEFAULT_EFFICIENCY * (power_level / 100.0)
total_seconds = (required_joules / effective_power_watts * time_factor) if effective_power_watts > 0 else 0
return {
"cook_time_seconds": round(total_seconds),
"effective_power_watts": round(effective_power_watts),
"recommended_power_level_pct": power_level,
"target_temp": target_temp,
"estimated_specific_heat": round(cp, 2),
"energy_joules": round(required_joules)
}
+3 -1
View File
@@ -1,3 +1,5 @@
Flask==3.0.2 Flask==3.0.2
pymongo==4.6.1 pymongo==4.6.1
gunicorn==21.2.0 gunicorn==21.2.0
opencv-python-headless
requests==2.32.3
+138 -28
View File
@@ -1,6 +1,13 @@
import _thread import _thread
from machine import Pin from machine import Pin, SoftI2C
from shared import get_lora, get_uart, deviceTypes, config from shared.safeQueue import SafeQueue
from shared import get_lora, get_uart, deviceTypes, config, cookingState
from shared.uart_comm import UARTCommand, UARTCommandType
from shared.sensors import RGBLED
from shared.logging import log
from shared.lora_device import LoraCommands
import framebuf
import ssd1306
import time import time
# --- Configuration Matérielle --- # --- Configuration Matérielle ---
@@ -18,52 +25,155 @@ except Exception:
# --- Initialisation LoRa --- # --- Initialisation LoRa ---
lora = get_lora() lora = get_lora()
lora.configure(freq=868.1, sf=7) lora.configure(freq=868.1, sf=7)
data_queue = SafeQueue()
# --- Création des lEDs RGB ---
magnetron_led = RGBLED(red_pin=48, green_pin=47, blue_pin=33)
magnetron_led.color = RGBLED.WHITE_YELLOW
magnetron_led.off()
# --- Création de l'écran OLED ---
scl_pin = Pin(18, Pin.OUT, pull=Pin.PULL_UP)
sda_pin = Pin(17, Pin.OUT, pull=Pin.PULL_UP)
display_i2c = SoftI2C(scl=scl_pin, sda=sda_pin, freq=100000)
display = ssd1306.SSD1306_I2C(128, 64, display_i2c, addr=0x3C)
display.text("Booting...", 1, 2, 1)
display.show()
print(f"ESP32 initialisé avec l'ID : '{DEVICE_ID}' (Type : {deviceTypes.DEVICE_TYPES['MICROWAVE']})") print(f"ESP32 initialisé avec l'ID : '{DEVICE_ID}' (Type : {deviceTypes.DEVICE_TYPES['MICROWAVE']})")
PING_PAYLOAD = {
"id": DEVICE_ID,
"type": deviceTypes.DEVICE_TYPES["MICROWAVE"]
}
def heartbeat_loop(): def heartbeat_loop():
last_heartbeat_time = 0
while True: while True:
print(f"\nESP32 : Envoi du Heartbeat...") now = time.time()
# Envoi périodique
ping_payload = {
"id": DEVICE_ID,
"type": deviceTypes.DEVICE_TYPES["MICROWAVE"]
}
lora.send(ping_payload)
# Le receive_packet est maintenant protégé par le lock dans lora_device # 1. Send periodic heartbeat
# Si le main thread utilise la radio, ce thread attendra son tour if now - last_heartbeat_time >= config.LORA_HEARTBEAT_INTERVAL:
paquet = lora.receive_packet(timeout_ms=2000) last_heartbeat_time = now
print("\nESP32 : Envoi du Heartbeat...")
lora.send(PING_PAYLOAD)
if paquet and not paquet["raw"]: # 2. Increase listen window to 300ms so radio stays active in RX mode
donnees = paquet["data"] paquet = lora.receive_reliable(timeout_ms=300)
# Vérification si le paquet reçu est bien la réponse attendue de l'orchestrateur
if donnees.get("type") == deviceTypes.DEVICE_TYPES["ORCHESTRATOR"]:
print(f"ESP32 : Réponse reçue de l'orchestrateur '{donnees.get('id')}' ! [Statut: ALIVE]")
else:
print(f"ESP32 : Paquet reçu d'un type inattendu : {donnees.get('type')}")
else:
print("ESP32 : Pas de réponse de l'orchestrateur (Le RPI est-il éteint ?)")
time.sleep(config.HEARTBEAT_INTERVAL) if paquet is not None:
log(f"[LoRa Thread] New Packet Received: {paquet}")
data_queue.put(paquet)
time.sleep_ms(10)
# UART # UART
uart_device = get_uart(uart_id=1, tx_pin=46, rx_pin=45) uart_device = get_uart(uart_id=1, tx_pin=46, rx_pin=45)
# Lancer la boucle de heartbeat dans un thread séparé # Lancer la boucle de heartbeat dans un thread séparé
try:
_thread.stack_size(16 * 1024)
except Exception:
pass
_thread.start_new_thread(heartbeat_loop, ()) _thread.start_new_thread(heartbeat_loop, ())
# Cooking parameters
cooking_state = None
def cooking_state_temperature_provider():
return 22.0, 29.0 # TODO Remplacer par la lecture réelle de la température du plat et de l'air ambiant
def cooking_state_on_state_change(state):
print(f"[Main] Cooking state changed to: {state.state}")
# Send to the Wifi board the current state
uart_device.send_as_command(UARTCommand(UARTCommandType.COOKING_STATE_UPDATE, {"state": state.state}))
# Send to the orchestrator the current state
lora.send_reliable({"id": DEVICE_ID, "new_cooking_state": state.state})
display.text(cookingState.CookingStates.get_state_name(state.state), 1, 2, 1)
display.show()
if state.paused or state.state == cookingState.CookingStates.DONE or state.state == cookingState.CookingStates.IDLE:
magnetron_led.off()
else:
magnetron_led.on()
if state.state == cookingState.CookingStates.COOKING:
pass
if state.state == cookingState.CookingStates.STIRRING_REQUIRED:
pass
if state.state == cookingState.CookingStates.DONE:
pass
if state.state == cookingState.CookingStates.ALERT:
pass
def cooking_state_on_refresh(state):
# TODO Show screen information
pass
def cooking_state_on_pause(state):
# If the cooking is unpaused and was in STIRRING_REQUIRED or ALERT state, we set the state back to COOKING.
if not state.paused and (state.state == cookingState.CookingStates.STIRRING_REQUIRED or state.state == cookingState.CookingStates.ALERT):
state.set_state(cookingState.CookingStates.COOKING)
# TODO send_reliable lora message to orchestrator about pause/resume state
# --- MAIN APPLICATION THREAD --- # --- MAIN APPLICATION THREAD ---
print("[Main] Main execution path active.") print("[Main] Main execution path active.")
while True: while True:
# 1. Listen for incoming UART serial packets from the WROOM board # 1. Listen for incoming UART serial packets from the WROOM board
while uart_device.any(): while uart_device.any():
command = uart_device.read() command = uart_device.read_as_command()
print(f"[Main] Received command from WiFi Board: {command}") if command:
print(f"[Main] Received command from WiFi Board: {command.command_type}")
if command.command_type == UARTCommandType.COOKING_PARAMS:
# Handle cooking parameters command
params = command.payload
print(f"[Main] Cooking parameters received: {params}")
cooking_state = cookingState.CookingState(
cook_time=params["cook_time"],
power_level=params["power_level"],
target_temp=params["target_temp"]
)
cooking_state.set_temperature_provider(cooking_state_temperature_provider)
cooking_state.set_state_change_callback(cooking_state_on_state_change)
cooking_state.set_refresh_callback(cooking_state_on_refresh)
cooking_state.set_pause_callback(cooking_state_on_pause)
time.sleep_ms(20) # Before sending back right away
cooking_state_on_state_change(cooking_state)
else:
print(f"[Main] Unknown command type received: {command.command_type}")
# 2. Listen for incoming LoRa packets from the orchestrator
while not data_queue.empty():
paquet = data_queue.get()
if paquet and not paquet["raw"]:
data = paquet["data"]
# Commands
if "action" in data:
if data["action"] == LoraCommands.TOGGLE_PAUSE:
if cooking_state != None:
if (cooking_state.state == cookingState.CookingStates.DONE):
print("[Main] Cooking is done. We reset the microwave for the next cooking session.")
cooking_state.set_state(cookingState.CookingStates.IDLE)
time.sleep_ms(20) # Before sending back right away
cooking_state = None
else:
cooking_state.toggle_pause()
if cooking_state.paused:
print("[Main] Cooking paused via orchestrator command.")
else:
print("[Main] Cooking resumed via orchestrator command.")
else:
log("[Main] No active cooking state to toggle pause/resume.")
# uart_device.send(f"Hello from esp-32 lora ID {DEVICE_ID}") # uart_device.send(f"Hello from esp-32 lora ID {DEVICE_ID}")
# 2. Send local metrics over the wire to the WiFi board every few seconds # Cooking State Update
# uart_device.send("Data Pack: LoRa Link RSSI -72dBm") if cooking_state != None:
cooking_state.update_tick()
print(f"[Main] Cooking state : State : {cooking_state.state}, Temperature: {cooking_state.current_dish_temp}, Paused: {cooking_state.paused}, Remaining Time: {cooking_state.get_remaining_time():.2f}s, Estimated Remaining Time: {cooking_state.get_remaining_time_estimation():.2f}s")
time.sleep_ms(200) time.sleep_ms(500)
+2 -2
View File
@@ -14,10 +14,10 @@ while True:
mesures = {"id": "ESP32_Salon", "temp": 22.4, "hum": 55.2} mesures = {"id": "ESP32_Salon", "temp": 22.4, "hum": 55.2}
# Envoi direct (le pilote s'occupe de mettre le groupe \x02) # Envoi direct (le pilote s'occupe de mettre le groupe \x02)
lora.send(b'\x02' + lora.send_json_bytes_helper if False else bytes([2]) + lora.send_helper if False else b'\x02' + __import__('ujson').dumps(mesures).encode('utf-8')) lora.send_reliable(b'\x02' + lora.send_json_bytes_helper if False else bytes([2]) + lora.send_helper if False else b'\x02' + __import__('ujson').dumps(mesures).encode('utf-8'))
# Réception propre # Réception propre
paquet = lora.receive_packet(3000) paquet = lora.receive_reliable(3000)
if paquet: if paquet:
# paquet est un dict : {"group": 2, "data": {...}, "raw": False} # paquet est un dict : {"group": 2, "data": {...}, "raw": False}
print(f"ESP32 : Message reçu du groupe {paquet['group']}") print(f"ESP32 : Message reçu du groupe {paquet['group']}")
+50 -11
View File
@@ -1,19 +1,58 @@
# This file is executed on every boot (including wake-boot from deepsleep) # This file is executed on every boot (including wake-boot from deepsleep)
import esp import esp
from machine import Pin
esp.osdebug(True) esp.osdebug(True)
#import webrepl #import webrepl
#webrepl.start() #webrepl.start()
def do_connect(ssid, pwd): # def do_connect(ssid, pwd):
import network # import network
sta_if = network.WLAN(network.STA_IF) # sta_if = network.WLAN(network.STA_IF)
if not sta_if.isconnected(): # sta_if.config(pm=sta_if.PM_NONE)
print('connecting to network...') # if not sta_if.isconnected():
sta_if.active(True) # print('connecting to network...')
sta_if.connect(ssid, pwd) # sta_if.active(True)
while not sta_if.isconnected(): # sta_if.connect(ssid, pwd)
pass # while not sta_if.isconnected():
print('network config:', sta_if.ifconfig()) # pass
# print('network config:', sta_if.ifconfig())
import network
import time
def do_connect(ssid, password):
wlan = network.WLAN(network.STA_IF)
# 1. ALWAYS activate the interface FIRST
if not wlan.active():
wlan.active(True)
# 2. Configure Wi-Fi options AFTER activation
try:
# Disable Wi-Fi modem sleep (0 = PM_NONE)
wlan.config(pm=0)
except Exception as e:
print("[Wi-Fi] Warning: Failed to set power management:", e)
# 3. Connect to the access point
if not wlan.isconnected():
print(f"[Wi-Fi] Connecting to {ssid}...")
wlan.connect(ssid, password)
timeout = 15
start_time = time.time()
while not wlan.isconnected():
if time.time() - start_time > timeout:
print("[Wi-Fi] Connection timed out!")
return False
time.sleep(0.5)
print("[Wi-Fi] Connected! Network config:", wlan.ifconfig())
return True
# Attempt to connect to WiFi network # Attempt to connect to WiFi network
# do_connect("Smartwave-1", 'Smartwave-prot-1') do_connect("Smartwave-1", 'Smartwave-prot-1')
# Set PIN 27 as GND for the temperature sensor (MLX90614)
sensor_gnd = Pin(27, Pin.OUT)
sensor_gnd.value(0)
+314 -141
View File
@@ -1,28 +1,12 @@
import _thread import gc
import select import sys
from machine import Pin
from sensors import temperature_gun
from shared import get_mqtt_client, get_uart, config, payloads
import time import time
import ujson as json import ujson as json
import sys import uasyncio as asyncio
from machine import Pin, I2C
# Simple thread-safe queue list # 1. Clean memory immediately before performing any operations
msg_queue = [] gc.collect()
queue_lock = _thread.allocate_lock()
def queue_publish(topic, payload):
"""Safely queues a message from the main thread."""
with queue_lock:
msg_queue.append((topic, payload))
# --- INITIALIZE CAMERA ---
try:
# Pass your confirmed working SCL and SDA pins here
temperature_gun.init_camera(scl_pin=21, sda_pin=22, freq=100000)
except Exception as e:
print("[Main] Critical: Camera setup failed!")
sys.print_exception(e)
# --- READ DEVICE ID --- # --- READ DEVICE ID ---
try: try:
@@ -30,152 +14,341 @@ try:
DEVICE_ID = f.read().strip() DEVICE_ID = f.read().strip()
except Exception: except Exception:
DEVICE_ID = "ESP32_Inconnu" DEVICE_ID = "ESP32_Inconnu"
# --- GLOBAL APP STATE ---
orchestrator_id = None
cooking_state = None
mqtt_connected = False
should_unsubscribe_hello = False
# --- ASYNC SIGNALS & QUEUES ---
# Event to signal when orchestrator requests sensor data (prevents MQTT lock deadlock)
sensor_request_event = None
# --- MQTT SETUP --- # --- MQTT SETUP ---
from shared import get_mqtt_client, config, payloads
MQTT_CA_FILE = "/certs/ca.crt" MQTT_CA_FILE = "/certs/ca.crt"
mqtt_client = get_mqtt_client( mqtt_client = get_mqtt_client(
host=config.MQTT_BROKER_HOST, host="192.168.50.1",
client_id="smartwave-esp32-" + DEVICE_ID, client_id="smartwave-esp32-demo",
use_tls=config.USE_TLS, use_tls=True,
cafile=MQTT_CA_FILE, cafile=MQTT_CA_FILE,
keepalive=config.MQTT_KEEPALIVE, keepalive=30,
) )
global orchestrator_id # --- HARDWARE & MODULE DEFERRED IMPORTS ---
orchestrator_id = None status_led = None
uart_device = None
mlx_temperature_sensor = None
cookingState = None
log = None
UARTCommand = None
UARTCommandType = None
def init_hardware():
"""Initializes hardware peripherals AFTER MQTT TLS has reserved its RAM."""
global status_led, uart_device, mlx_temperature_sensor
global cookingState, log, UARTCommand, UARTCommandType
print("[Main] Initializing hardware peripherals...")
from shared import get_uart, cookingState as cs, logging
from shared.uart_comm import UARTCommand as UC, UARTCommandType as UCT
from shared.sensors import RGBLED
from sensors import temperature_sensor
cookingState = cs
log = logging.log
UARTCommand = UC
UARTCommandType = UCT
status_led = RGBLED(red_pin=21, green_pin=19, blue_pin=18)
uart_device = get_uart(uart_id=2, tx_pin=17, rx_pin=16)
temperature_sensor_i2c = I2C(
0,
scl=Pin(25, Pin.IN, Pin.PULL_UP),
sda=Pin(26, Pin.IN, Pin.PULL_UP),
freq=100000,
)
devices = temperature_sensor_i2c.scan()
if 0x5A in devices:
print("[Main] MLX90614 found at address 0x5A!")
else:
print("[Main] MLX90614 not found on I2C bus.")
mlx_temperature_sensor = temperature_sensor.MLX90614(temperature_sensor_i2c)
def on_received_cooking_state_update(state, is_error=False, is_terminated=False):
"""Callback executed when state changes are received from the LoRa board over UART."""
if cooking_state:
if is_error:
cooking_state.set_state(cookingState.CookingStates.ERROR)
elif is_terminated:
cooking_state.set_state(cookingState.CookingStates.ABORTED)
else:
cooking_state.set_state(state)
def on_cooking_state_change(state):
"""Callback executed whenever local cooking state transitions."""
BLINK_INTERVAL_MS = 500
if status_led and cookingState:
if state == cookingState.CookingStates.IDLE:
status_led.color = status_led.OFF
status_led.blink_off()
elif state == cookingState.CookingStates.COOKING:
status_led.color = status_led.YELLOW
status_led.blink_off()
elif state == cookingState.CookingStates.STIRRING_REQUIRED:
status_led.color = status_led.ORANGE
status_led.blink_on(BLINK_INTERVAL_MS)
elif state == cookingState.CookingStates.ALERT:
status_led.color = status_led.RED
status_led.blink_on(BLINK_INTERVAL_MS)
elif state == cookingState.CookingStates.DONE:
status_led.color = status_led.GREEN
status_led.blink_off()
def on_mqtt_message(message): def on_mqtt_message(message):
print("[MQTT Thread] Received message:", message) """Sync callback: Lightweight! Only updates variables or triggers async signals."""
global orchestrator_id, cooking_state, should_unsubscribe_hello
# Try and parse the payload as json, but if it fails, just print the raw payload print("[MQTT] Received message on topic:", message.get("topic"))
payload_data=None
payload_data = None
try: try:
payload_data = json.loads(message['payload']) payload_data = json.loads(message["payload"])
except Exception as e: except Exception as e:
print("[MQTT Thread] Error parsing JSON:", e) print("[MQTT] Payload parsing warning:", e)
sys.print_exception(e)
pass # Maybe it's not JSON
if message['topic'] == config.MQTT_TOPIC_HELLO and payload_data and "id_orchestrator" in payload_data and payload_data["id_microwave"] == DEVICE_ID:
print("[MQTT Thread] Hello response received from orchestrator:", payload_data["id_orchestrator"])
global orchestrator_id
orchestrator_id = payload_data["id_orchestrator"]
# Unsubscribe from the hello topic since we got a response
mqtt_client.unsubscribe(config.MQTT_TOPIC_HELLO)
print("[MQTT Thread] Unsubscribed from topic:", config.MQTT_TOPIC_HELLO)
print("[MQTT Thread] Message processing complete.")
mqtt_client.set_callback(on_mqtt_message) topic = message.get("topic")
# 1. Orchestrator Hello Response
if (
topic == config.MQTT_TOPIC_HELLO
and payload_data
and payload_data.get("id_microwave") == DEVICE_ID
):
orchestrator_id = payload_data.get("id_orchestrator")
print("[MQTT] Hello response received from orchestrator:", orchestrator_id)
should_unsubscribe_hello = True
# 2. Cooking Parameters / Sensor Request
elif (
topic == config.MQTT_TOPIC_COOKING
and payload_data
and payload_data.get("id_microwave") == DEVICE_ID
):
if "cook_time" not in payload_data:
print("[MQTT] Sensor data requested! Triggering async publisher...")
# Trigger async event instead of calling publish() directly inside lock context!
sensor_request_event.set()
else:
print("[MQTT] Cooking parameters received:", payload_data)
if cookingState:
cooking_state = cookingState.CookingState(
cook_time=payload_data["cook_time"],
power_level=payload_data["power_level"],
target_temp=payload_data["target_temp"],
)
cooking_state.set_state_change_callback(on_cooking_state_change)
cooking_state.set_state(cookingState.CookingStates.IDLE)
if uart_device and UARTCommand:
uart_device.send_as_command(
UARTCommand(UARTCommandType.COOKING_PARAMS, payload_data)
)
print("[MQTT] Cooking parameters sent to LoRa board over UART.")
def mqtt_background_thread(): # --- DEDICATED ASYNC TASK FOR SENSOR PUBLISHING ---
"""Background MQTT worker handling ALL socket operations safely.""" async def sensor_publisher_task():
print("[Thread] Background MQTT worker started.") """Waits for sensor_request_event, reads hardware, and publishes outside the MQTT lock."""
while True:
await sensor_request_event.wait()
sensor_request_event.clear()
print("[Sensor Task] Reading temperature sensors...")
obj_temp = (
mlx_temperature_sensor.read_object_temp()
if mlx_temperature_sensor
else 0
)
amb_temp = (
mlx_temperature_sensor.read_ambient_temp()
if mlx_temperature_sensor
else 0
)
sensor_payload = payloads.mqtt_sensor_data(DEVICE_ID, obj_temp, amb_temp)
try:
print("[Sensor Task] Publishing sensor data to MQTT...")
mqtt_client.publish(
config.MQTT_TOPIC_SENSOR, sensor_payload, qos=config.MQTT_QOS
)
print("[Sensor Task] Sensor data successfully published:", sensor_payload)
except Exception as e:
print("[Sensor Task] Failed to publish sensor data:", e)
async def uart_task():
"""Polls incoming UART messages from the LoRa board using dynamic method fallback."""
while True:
if uart_device:
try:
cmd = uart_device.read_as_command()
if cmd:
print("[UART] Command received from LoRa board:", cmd)
if (
hasattr(cmd, "command_type")
and cmd.command_type == UARTCommandType.STATE_UPDATE
and on_received_cooking_state_update
):
on_received_cooking_state_update(
cmd.payload.get("state"),
cmd.payload.get("is_error", False),
cmd.payload.get("is_terminated", False),
)
except Exception as e:
print("[UART Task] Error reading command:", e)
await asyncio.sleep_ms(50)
async def connect_mqtt_async():
global mqtt_connected, mqtt_client
mqtt_connected = False
while True: while True:
try: try:
print("[Thread] Attempting connection to MQTT broker...") print("[MQTT] Connecting to broker with TLS...")
# Re-instantiate client to clear old socket buffers
gc.collect()
mqtt_client = get_mqtt_client(
host="192.168.50.1", # TODO : Use config.MQTT_BROKER_HOST instead of hardcoding
port=8884,
client_id="smartwave-esp32-demo",
use_tls=True,
cafile=MQTT_CA_FILE,
keepalive=30,
)
mqtt_client.set_callback(on_mqtt_message)
mqtt_client.connect() mqtt_client.connect()
print("[Thread] Connected! Subscribing to topic...") print("[MQTT] Connected! Subscribing to topics...")
mqtt_client.subscribe(config.MQTT_TOPIC_COOKING, qos=config.MQTT_QOS) mqtt_client.subscribe(config.MQTT_TOPIC_COOKING, qos=config.MQTT_QOS)
print("[Thread] Successfully subscribed. Setting up poller...") mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS)
print("[MQTT] Subscribed successfully!")
poller = select.poll() mqtt_connected = True
poller.register(mqtt_client._client.sock, select.POLLIN) return
last_check = time.time()
while True:
# 1. Process outbound messages queued by the main thread
while len(msg_queue) > 0:
with queue_lock:
topic, payload = msg_queue.pop(0)
print(f"[Thread] Safely publishing queued message to {topic}...")
mqtt_client.publish(topic, payload, qos=config.MQTT_QOS)
# 2. Check for incoming messages (non-blocking poll)
# Shortened timeout to keep the queue responsive
events = poller.poll(200)
if events:
mqtt_client.wait()
# 3. Handle Keepalive tracking manually
if time.time() - last_check >= 15:
# print("[Thread] Sending keepalive ping...")
mqtt_client._client.ping()
last_check = time.time()
# Small breathe room for the CPU core
time.sleep_ms(50)
except Exception as e: except Exception as e:
print("[Thread] Connection dropped or error encountered:", e) print("[MQTT] Connection failed:", e)
sys.print_exception(e) sys.print_exception(e)
print("[Thread] Cleaning up socket context. Retrying in 5 seconds...")
# --- FIX FOR ERROR 23 (SOCKET LEAK) ---
# Manually force-kill the underlying socket file descriptor if it exists
try:
if mqtt_client._client and hasattr(mqtt_client._client, "sock"):
if mqtt_client._client.sock is not None:
mqtt_client._client.sock.close()
except Exception:
pass # Already dead or closed
# Now we let the wrapper do its normal cleanup safely
try: try:
mqtt_client.close() mqtt_client.close()
except Exception: except Exception:
pass pass
time.sleep(5)
# Force heap cleanup before sleeping
del mqtt_client
gc.collect()
print(f"[MQTT] Free RAM after cleanup: {gc.mem_free()} bytes")
print("[MQTT] Retrying connection in 5 seconds...")
await asyncio.sleep(5)
async def mqtt_poll_task():
global mqtt_connected
last_ping = time.time()
# --- UART BACKGROUND THREAD ---
def uart_background_thread():
"""Background UART worker handling all serial operations safely."""
print("[Thread] Background UART worker started.")
uart_device = get_uart(uart_id=2, tx_pin=17, rx_pin=16)
while True: while True:
try: if mqtt_connected:
# 1. Check for incoming messages from the Heltec board try:
while uart_device.any(): mqtt_client.poll()
incoming_msg = uart_device.read() now = time.time()
print(f"[Thread] Received from esp-lora over UART: {incoming_msg}") if now - last_ping >= 15:
mqtt_client.ping()
last_ping = now
except OSError as e:
print("[MQTT Task] Socket error encountered during poll/ping:", e)
mqtt_connected = False
await connect_mqtt_async()
await asyncio.sleep_ms(30)
async def orchestrator_hello_task():
global mqtt_connected, should_unsubscribe_hello
while True:
if orchestrator_id is not None:
if should_unsubscribe_hello:
try:
mqtt_client.unsubscribe(config.MQTT_TOPIC_HELLO)
should_unsubscribe_hello = False
print("[MQTT] Successfully unsubscribed from hello topic.")
except Exception as e:
print("[MQTT] Unsubscribe error:", e)
# Hello successfully acknowledged! Stop looping this task.
print("[Hello Task] Orchestrator acknowledged. Stopping hello task.")
break
# 2. Example: Send data to the Heltec board every 5 seconds if mqtt_connected:
# uart_device.send("Status Check: WiFi Active") print("[Hello Task] Sending initial hello to orchestrator...")
try:
time.sleep(5) # Fast responsive polling loop for local UART if mqtt_client is None:
print("[Hello Task] MQTT client is None. Attempting to reconnect...")
except Exception as e: await connect_mqtt_async()
print("[Thread] UART error encountered:", e)
time.sleep(5) mqtt_client.publish(
config.MQTT_TOPIC_HELLO,
payloads.mqtt_hello(DEVICE_ID),
qos=config.MQTT_QOS,
)
except OSError as e:
print("[Hello Task] Hello publish failed:", e)
# mqtt_connected = False
# --- Launch background worker --- await asyncio.sleep(config.MQTT_HELLO_INTERVAL)
# _thread.start_new_thread(mqtt_background_thread, ())
# _thread.start_new_thread(uart_background_thread, ())
# --- MAIN APPLICATION THREAD (Core 0) --- async def memory_cleanup_task():
print("[Main] Main execution path active.") while True:
time.sleep(2) # Give the thread a moment to initial connect gc.collect()
mqtt_hello_sent_timestamp = -config.MQTT_HELLO_INTERVAL await asyncio.sleep(10)
mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS)
while True:
# MQTT HELLO sent every x seconds until we get a response from the orchestrator # --- MAIN ENTRY POINT ---
if (orchestrator_id == None and -(mqtt_hello_sent_timestamp - time.time()) > config.MQTT_HELLO_INTERVAL): async def main():
print("[Main] Attempting to send initial hello to orchestrator...") global sensor_request_event
queue_publish(config.MQTT_TOPIC_HELLO, payloads.mqtt_hello(DEVICE_ID)) print("[Main] Starting application...")
mqtt_hello_sent_timestamp = time.time()
pass # Initialize loop-bound events
sensor_request_event = asyncio.Event()
# Sensors
print(f"[Main] Reading temperature from the gun sensor...") await connect_mqtt_async()
temp = temperature_gun.read_temperature() init_hardware()
print(f"[Main] Temperature read: {temp}°C")
# Launch background tasks
# 2. Example: Send data to the Heltec board every 5 seconds asyncio.create_task(mqtt_poll_task())
# uart_device.send("Status Check: WiFi Active") asyncio.create_task(orchestrator_hello_task())
time.sleep(1) asyncio.create_task(sensor_publisher_task())
asyncio.create_task(uart_task())
asyncio.create_task(memory_cleanup_task())
print("[Main] All tasks running concurrently!")
while True:
await asyncio.sleep(3600)
try:
asyncio.run(main())
except KeyboardInterrupt:
print("[Main] Program stopped by user.")
+1 -1
View File
@@ -1 +1 @@
import sensors.temperature_gun as temperature_gun import sensors.temperature_sensor as temperature_sensor
@@ -1,969 +0,0 @@
"""
Temperatue gun sensor module
using the MLX90640-D55/D110 sensor. This module provides a function to read the temperature from the gun sensor.
Resolution of 32x24 pixels,
I2C interface
Noise Equivalent Temperature difference (NETD) is 0.1K RMS @ 1Hz refresh rate
"""
import machine # type: ignore
import math
import struct
import time
from micropython import const# Some libraries that we will use
import time
class RefreshRate: # pylint: disable=too-few-public-methods
""" Enum-like class for MLX90640's refresh rate """
REFRESH_0_5_HZ = const(0b000) # 0.5Hz
REFRESH_1_HZ = const(0b001) # 1Hz
REFRESH_2_HZ = const(0b010) # 2Hz
REFRESH_4_HZ = const(0b011) # 4Hz
REFRESH_8_HZ = const(0b100) # 8Hz
REFRESH_16_HZ = const(0b101) # 16Hz
REFRESH_32_HZ = const(0b110) # 32Hz
REFRESH_64_HZ = const(0b111) # 64Hz
class ContextManaged:
"""An object that automatically deinitializes hardware with a context manager."""
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
self.deinit()
# pylint: disable=no-self-use
def deinit(self):
"""Free any hardware used by the object."""
return
class Lockable(ContextManaged):
"""An object that must be locked to prevent collisions on a microcontroller resource."""
_locked = False
def try_lock(self):
"""Attempt to grab the lock. Return True on success, False if the lock is already taken."""
if self._locked:
return False
self._locked = True
return True
def unlock(self):
"""Release the lock so others may use the resource."""
if self._locked:
self._locked = False
else:
raise ValueError("Not locked")
class I2C(Lockable):
def __init__(self, pins=(21, 22), frequency=100000):
self.init(pins, frequency)
def init(self, pins, frequency):
self.deinit()
# 1. Force the ESP32 to activate its internal pull-up resistors on these pins
self._pins = (
machine.Pin(int(pins[0]), machine.Pin.IN, machine.Pin.PULL_UP),
machine.Pin(int(pins[1]), machine.Pin.IN, machine.Pin.PULL_UP)
)
try:
# 2. Bypasses the glitchy ESP32 hardware block using SoftI2C
# (Note: SoftI2C does not take a bus ID number like '0')
self._i2c = machine.SoftI2C(scl=self._pins[0], sda=self._pins[1], freq=frequency)
except RuntimeError:
raise
print(f"Created resilient SoftI2C: {self._i2c}")
def deinit(self):
try:
del self._i2c
except AttributeError:
pass
def scan(self):
return self._i2c.scan()
def readfrom_into(self, address, buffer, *, start=0, end=None):
if start is not 0 or end is not None:
if end is None:
end = len(buffer)
buffer = memoryview(buffer)[start:end]
stop = True # remove for efficiency later
return self._i2c.readfrom_into(address, buffer)
def writeto(self, address, buffer, *, start=0, end=None, stop=True):
if isinstance(buffer, str):
buffer = bytes([ord(x) for x in buffer])
if start is not 0 or end is not None:
if end is None:
return self._i2c.writeto(address, memoryview(buffer)[start:], stop)
else:
return self._i2c.writeto(address, memoryview(buffer)[start:end], stop)
return self._i2c.writeto(address, buffer, stop)
class I2CDevice:
def __init__(self, i2c, device_address, probe=True):
self.i2c = i2c
self._has_write_read = False # hasattr(self.i2c, "writeto_then_readfrom") --> has been turned to False
self.device_address = device_address
if probe:
self.__probe_for_device()
def readinto(self, buf, *, start=0, end=None):
if end is None:
end = len(buf)
self.i2c.readfrom_into(self.device_address, buf, start=start, end=end)
def write(self, buf, *, start=0, end=None, stop=True):
if end is None:
end = len(buf)
self.i2c.writeto(self.device_address, buf, start=start, end=end, stop=stop)
# pylint: disable-msg=too-many-arguments
def write_then_readinto(
self,
out_buffer,
in_buffer,
*,
out_start=0,
out_end=None,
in_start=0,
in_end=None,
stop=False
):
if out_end is None:
out_end = len(out_buffer)
if in_end is None:
in_end = len(in_buffer)
if stop:
raise ValueError("Stop must be False. Use writeto instead.")
if self._has_write_read:
#print("c",dir(self.i2c))
# In linux, at least, this is a special kernel function call
self.i2c.writeto_then_readfrom(
self.device_address,
out_buffer,
in_buffer,
out_start=out_start,
out_end=out_end,
in_start=in_start,
in_end=in_end,
)
else:
# If we don't have a special implementation, we can fake it with two calls
self.i2c.writeto(self.device_address, out_buffer, stop=False) # These lines have been changed to make it work with wipy micropython I2C module
#self.write(out_buffer, start=out_start, end=out_end, stop=False)
#self.readinto(in_buffer, start=in_start, end=in_end)
self.i2c.readfrom_into(self.device_address, in_buffer) # These lines have been changed to make it work with wipy micropython I2C module
# pylint: enable-msg=too-many-arguments
def __enter__(self):
while not self.i2c.try_lock():
pass
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.i2c.unlock()
return False
def __probe_for_device(self):
"""
Try to read a byte from an address,
if you get an OSError it means the device is not there
or that the device does not support these means of probing
"""
while not self.i2c.try_lock():
pass
try:
self.i2c.writeto(self.device_address, b"")
except OSError:
# some OS's dont like writing an empty bytesting...
# Retry by reading a byte
try:
result = bytearray(1)
self.i2c.readfrom_into(self.device_address, result)
except OSError:
raise ValueError("No I2C device at address: %x" % self.device_address)
finally:
self.i2c.unlock()
eeData = [0] * const(832)
I2C_READ_LEN = const(2048)
SCALEALPHA = const(0.000001)
MLX90640_DEVICEID1 = const(0x2407)
OPENAIR_TA_SHIFT = const(8)
class MLX90640: # pylint: disable=too-many-instance-attributes
"""Interface to the MLX90640 temperature sensor."""
kVdd = 0
vdd25 = 0
KvPTAT = 0
KtPTAT = 0
vPTAT25 = 0
alphaPTAT = 0
gainEE = 0
tgc = 0
KsTa = 0
resolutionEE = 0
calibrationModeEE = 0
ksTo = [0] * 5
ct = [0] * 5
alpha = [0] * 768
alphaScale = 0
offset = [0] * 768
kta = [0] * 768
ktaScale = 0
kv = [0] * 768
kvScale = 0
cpAlpha = [0] * 2
cpOffset = [0] * 2
ilChessC = [0] * 3
brokenPixels = [0xFFFF] * 5
outlierPixels = [0xFFFF] * 5
cpKta = 0
cpKv = 0
def __init__(self, i2c_bus, address=0x33):
self.i2c_device = I2CDevice(i2c_bus, address)
self._I2CReadWords(0x2400, eeData)
# print(eeData)
self._ExtractParameters()
@property
def serial_number(self):
""" 3-item tuple of hex values that are unique to each MLX90640 """
serialWords = [0, 0, 0]
self._I2CReadWords(MLX90640_DEVICEID1, serialWords)
return serialWords
@property
def refresh_rate(self):
""" How fast the MLX90640 will spit out data. Start at lowest speed in
RefreshRate and then slowly increase I2C clock rate and rate until you
max out. The sensor does not like it if the I2C host cannot 'keep up'!"""
controlRegister = [0]
self._I2CReadWords(0x800D, controlRegister)
return (controlRegister[0] >> 7) & 0x07
@refresh_rate.setter
def refresh_rate(self, rate):
controlRegister = [0]
value = (rate & 0x7) << 7
self._I2CReadWords(0x800D, controlRegister)
value |= controlRegister[0] & 0xFC7F
self._I2CWriteWord(0x800D, value)
def getFrame(self, framebuf):
""" Request both 'halves' of a frame from the sensor, merge them
and calculate the temperature in C for each of 32x24 pixels. Placed
into the 768-element array passed in! """
emissivity = 0.95
tr = 23.15
mlx90640Frame = [0] * 834
for _ in range(2):
status = self._GetFrameData(mlx90640Frame)
if status < 0:
raise RuntimeError("Frame data error")
# For a MLX90640 in the open air the shift is -8 degC.
tr = self._GetTa(mlx90640Frame) - OPENAIR_TA_SHIFT
self._CalculateTo(mlx90640Frame, emissivity, tr, framebuf)
def _GetFrameData(self, frameData):
dataReady = 0
cnt = 0
statusRegister = [0]
controlRegister = [0]
while dataReady == 0:
self._I2CReadWords(0x8000, statusRegister)
dataReady = statusRegister[0] & 0x0008
# print("ready status: 0x%x" % dataReady)
while (dataReady != 0) and (cnt < 5):
self._I2CWriteWord(0x8000, 0x0030)
# print("Read frame", cnt)
self._I2CReadWords(0x0400, frameData, end=832)
self._I2CReadWords(0x8000, statusRegister)
dataReady = statusRegister[0] & 0x0008
# print("frame ready: 0x%x" % dataReady)
cnt += 1
if cnt > 4:
raise RuntimeError("Too many retries")
self._I2CReadWords(0x800D, controlRegister)
frameData[832] = controlRegister[0]
frameData[833] = statusRegister[0] & 0x0001
return frameData[833]
def _GetTa(self, frameData):
vdd = self._GetVdd(frameData)
ptat = frameData[800]
if ptat > 32767:
ptat -= 65536
ptatArt = frameData[768]
if ptatArt > 32767:
ptatArt -= 65536
ptatArt = (ptat / (ptat * self.alphaPTAT + ptatArt)) * math.pow(2, 18)
ta = ptatArt / (1 + self.KvPTAT * (vdd - 3.3)) - self.vPTAT25
ta = ta / self.KtPTAT + 25
return ta
def _GetVdd(self, frameData):
vdd = frameData[810]
if vdd > 32767:
vdd -= 65536
resolutionRAM = (frameData[832] & 0x0C00) >> 10
resolutionCorrection = math.pow(2, self.resolutionEE) / math.pow(
2, resolutionRAM
)
vdd = (resolutionCorrection * vdd - self.vdd25) / self.kVdd + 3.3
return vdd
def _CalculateTo(self, frameData, emissivity, tr, result):
# pylint: disable=too-many-locals, too-many-branches, too-many-statements
subPage = frameData[833]
alphaCorrR = [0] * 4
irDataCP = [0, 0]
vdd = self._GetVdd(frameData)
ta = self._GetTa(frameData)
ta4 = ta + 273.15
ta4 = ta4 * ta4
ta4 = ta4 * ta4
tr4 = tr + 273.15
tr4 = tr4 * tr4
tr4 = tr4 * tr4
taTr = tr4 - (tr4 - ta4) / emissivity
ktaScale = math.pow(2, self.ktaScale)
kvScale = math.pow(2, self.kvScale)
alphaScale = math.pow(2, self.alphaScale)
alphaCorrR[0] = 1 / (1 + self.ksTo[0] * 40)
alphaCorrR[1] = 1
alphaCorrR[2] = 1 + self.ksTo[1] * self.ct[2]
alphaCorrR[3] = alphaCorrR[2] * (1 + self.ksTo[2] * (self.ct[3] - self.ct[2]))
# --------- Gain calculation -----------------------------------
gain = frameData[778]
if gain > 32767:
gain -= 65536
gain = self.gainEE / gain
# --------- To calculation -------------------------------------
mode = (frameData[832] & 0x1000) >> 5
irDataCP[0] = frameData[776]
irDataCP[1] = frameData[808]
for i in range(2):
if irDataCP[i] > 32767:
irDataCP[i] -= 65536
irDataCP[i] *= gain
irDataCP[0] -= (
self.cpOffset[0]
* (1 + self.cpKta * (ta - 25))
* (1 + self.cpKv * (vdd - 3.3))
)
if mode == self.calibrationModeEE:
irDataCP[1] -= (
self.cpOffset[1]
* (1 + self.cpKta * (ta - 25))
* (1 + self.cpKv * (vdd - 3.3))
)
else:
irDataCP[1] -= (
(self.cpOffset[1] + self.ilChessC[0])
* (1 + self.cpKta * (ta - 25))
* (1 + self.cpKv * (vdd - 3.3))
)
for pixelNumber in range(768):
ilPattern = pixelNumber // 32 - (pixelNumber // 64) * 2
chessPattern = ilPattern ^ (pixelNumber - (pixelNumber // 2) * 2)
conversionPattern = (
(pixelNumber + 2) // 4
- (pixelNumber + 3) // 4
+ (pixelNumber + 1) // 4
- pixelNumber // 4
) * (1 - 2 * ilPattern)
if mode == 0:
pattern = ilPattern
else:
pattern = chessPattern
if pattern == frameData[833]:
irData = frameData[pixelNumber]
if irData > 32767:
irData -= 65536
irData *= gain
kta = self.kta[pixelNumber] / ktaScale
kv = self.kv[pixelNumber] / kvScale
irData -= (
self.offset[pixelNumber]
* (1 + kta * (ta - 25))
* (1 + kv * (vdd - 3.3))
)
if mode != self.calibrationModeEE:
irData += (
self.ilChessC[2] * (2 * ilPattern - 1)
- self.ilChessC[1] * conversionPattern
)
irData = irData - self.tgc * irDataCP[subPage]
irData /= emissivity
alphaCompensated = SCALEALPHA * alphaScale / self.alpha[pixelNumber]
alphaCompensated *= 1 + self.KsTa * (ta - 25)
Sx = (
alphaCompensated
* alphaCompensated
* alphaCompensated
* (irData + alphaCompensated * taTr)
)
Sx = math.sqrt(math.sqrt(Sx)) * self.ksTo[1]
To = (
math.sqrt(
math.sqrt(
irData
/ (alphaCompensated * (1 - self.ksTo[1] * 273.15) + Sx)
+ taTr
)
)
- 273.15
)
if To < self.ct[1]:
torange = 0
elif To < self.ct[2]:
torange = 1
elif To < self.ct[3]:
torange = 2
else:
torange = 3
To = (
math.sqrt(
math.sqrt(
irData
/ (
alphaCompensated
* alphaCorrR[torange]
* (1 + self.ksTo[torange] * (To - self.ct[torange]))
)
+ taTr
)
)
- 273.15
)
result[pixelNumber] = To
# pylint: enable=too-many-locals, too-many-branches, too-many-statements
def _ExtractParameters(self):
self._ExtractVDDParameters()
self._ExtractPTATParameters()
self._ExtractGainParameters()
self._ExtractTgcParameters()
self._ExtractResolutionParameters()
self._ExtractKsTaParameters()
self._ExtractKsToParameters()
self._ExtractCPParameters()
self._ExtractAlphaParameters()
self._ExtractOffsetParameters()
self._ExtractKtaPixelParameters()
self._ExtractKvPixelParameters()
self._ExtractCILCParameters()
self._ExtractDeviatingPixels()
def _ExtractVDDParameters(self):
# extract VDD
self.kVdd = (eeData[51] & 0xFF00) >> 8
if self.kVdd > 127:
self.kVdd -= 256 # convert to signed
self.kVdd *= 32
self.vdd25 = eeData[51] & 0x00FF
self.vdd25 = ((self.vdd25 - 256) << 5) - 8192
def _ExtractPTATParameters(self):
# extract PTAT
self.KvPTAT = (eeData[50] & 0xFC00) >> 10
if self.KvPTAT > 31:
self.KvPTAT -= 64
self.KvPTAT /= 4096
self.KtPTAT = eeData[50] & 0x03FF
if self.KtPTAT > 511:
self.KtPTAT -= 1024
self.KtPTAT /= 8
self.vPTAT25 = eeData[49]
self.alphaPTAT = (eeData[16] & 0xF000) / math.pow(2, 14) + 8
def _ExtractGainParameters(self):
# extract Gain
self.gainEE = eeData[48]
if self.gainEE > 32767:
self.gainEE -= 65536
def _ExtractTgcParameters(self):
# extract Tgc
#print(eeData[60])
self.tgc = eeData[60] & 0x00FF
#print(self.tgc)
if self.tgc > 127:
self.tgc -= 256
self.tgc /= 32
#print(self.tgc)
def _ExtractResolutionParameters(self):
# extract resolution
self.resolutionEE = (eeData[56] & 0x3000) >> 12
def _ExtractKsTaParameters(self):
# extract KsTa
self.KsTa = (eeData[60] & 0xFF00) >> 8
if self.KsTa > 127:
self.KsTa -= 256
self.KsTa /= 8192
def _ExtractKsToParameters(self):
# extract ksTo
step = ((eeData[63] & 0x3000) >> 12) * 10
self.ct[0] = -40
self.ct[1] = 0
self.ct[2] = (eeData[63] & 0x00F0) >> 4
self.ct[3] = (eeData[63] & 0x0F00) >> 8
self.ct[2] *= step
self.ct[3] = self.ct[2] + self.ct[3] * step
KsToScale = (eeData[63] & 0x000F) + 8
KsToScale = 1 << KsToScale
self.ksTo[0] = eeData[61] & 0x00FF
self.ksTo[1] = (eeData[61] & 0xFF00) >> 8
self.ksTo[2] = eeData[62] & 0x00FF
self.ksTo[3] = (eeData[62] & 0xFF00) >> 8
for i in range(4):
if self.ksTo[i] > 127:
self.ksTo[i] -= 256
self.ksTo[i] /= KsToScale
self.ksTo[4] = -0.0002
def _ExtractCPParameters(self):
# extract CP
offsetSP = [0] * 2
alphaSP = [0] * 2
alphaScale = ((eeData[32] & 0xF000) >> 12) + 27
offsetSP[0] = eeData[58] & 0x03FF
if offsetSP[0] > 511:
offsetSP[0] -= 1024
offsetSP[1] = (eeData[58] & 0xFC00) >> 10
if offsetSP[1] > 31:
offsetSP[1] -= 64
offsetSP[1] += offsetSP[0]
alphaSP[0] = eeData[57] & 0x03FF
if alphaSP[0] > 511:
alphaSP[0] -= 1024
alphaSP[0] /= math.pow(2, alphaScale)
alphaSP[1] = (eeData[57] & 0xFC00) >> 10
if alphaSP[1] > 31:
alphaSP[1] -= 64
alphaSP[1] = (1 + alphaSP[1] / 128) * alphaSP[0]
cpKta = eeData[59] & 0x00FF
if cpKta > 127:
cpKta -= 256
ktaScale1 = ((eeData[56] & 0x00F0) >> 4) + 8
self.cpKta = cpKta / math.pow(2, ktaScale1)
cpKv = (eeData[59] & 0xFF00) >> 8
if cpKv > 127:
cpKv -= 256
kvScale = (eeData[56] & 0x0F00) >> 8
self.cpKv = cpKv / math.pow(2, kvScale)
self.cpAlpha[0] = alphaSP[0]
self.cpAlpha[1] = alphaSP[1]
self.cpOffset[0] = offsetSP[0]
self.cpOffset[1] = offsetSP[1]
#print(self.cpAlpha[0])
#print(self.cpAlpha[1])
def _ExtractAlphaParameters(self):
# extract alpha
accRemScale = eeData[32] & 0x000F
accColumnScale = (eeData[32] & 0x00F0) >> 4
accRowScale = (eeData[32] & 0x0F00) >> 8
alphaScale = ((eeData[32] & 0xF000) >> 12) + 30
alphaRef = eeData[33]
accRow = [0] * 24
accColumn = [0] * 32
alphaTemp = [0] * 768
for i in range(6):
p = i * 4
accRow[p + 0] = eeData[34 + i] & 0x000F
accRow[p + 1] = (eeData[34 + i] & 0x00F0) >> 4
accRow[p + 2] = (eeData[34 + i] & 0x0F00) >> 8
accRow[p + 3] = (eeData[34 + i] & 0xF000) >> 12
for i in range(24):
if accRow[i] > 7:
accRow[i] -= 16
for i in range(8):
p = i * 4
accColumn[p + 0] = eeData[40 + i] & 0x000F
accColumn[p + 1] = (eeData[40 + i] & 0x00F0) >> 4
accColumn[p + 2] = (eeData[40 + i] & 0x0F00) >> 8
accColumn[p + 3] = (eeData[40 + i] & 0xF000) >> 12
for i in range(32):
if accColumn[i] > 7:
accColumn[i] -= 16
for i in range(24):
for j in range(32):
p = 32 * i + j
alphaTemp[p] = (eeData[64 + p] & 0x03F0) >> 4
if alphaTemp[p] > 31:
alphaTemp[p] -= 64
alphaTemp[p] *= 1 << accRemScale
alphaTemp[p] += (
alphaRef
+ (accRow[i] << accRowScale)
+ (accColumn[j] << accColumnScale)
)
alphaTemp[p] /= math.pow(2, alphaScale)
alphaTemp[p] -= self.tgc * (self.cpAlpha[0] + self.cpAlpha[1]) / 2
alphaTemp[p] = SCALEALPHA / alphaTemp[p]
# print("alphaTemp: ", alphaTemp)
temp = max(alphaTemp)
#print("temp", temp)
alphaScale = 0
while temp < 32768:
temp *= 2
alphaScale += 1
for i in range(768):
temp = alphaTemp[i] * math.pow(2, alphaScale)
self.alpha[i] = int(temp + 0.5)
self.alphaScale = alphaScale
def _ExtractOffsetParameters(self):
# extract offset
occRow = [0] * 24
occColumn = [0] * 32
occRemScale = eeData[16] & 0x000F
occColumnScale = (eeData[16] & 0x00F0) >> 4
occRowScale = (eeData[16] & 0x0F00) >> 8
offsetRef = eeData[17]
if offsetRef > 32767:
offsetRef -= 65536
for i in range(6):
p = i * 4
occRow[p + 0] = eeData[18 + i] & 0x000F
occRow[p + 1] = (eeData[18 + i] & 0x00F0) >> 4
occRow[p + 2] = (eeData[18 + i] & 0x0F00) >> 8
occRow[p + 3] = (eeData[18 + i] & 0xF000) >> 12
for i in range(24):
if occRow[i] > 7:
occRow[i] -= 16
for i in range(8):
p = i * 4
occColumn[p + 0] = eeData[24 + i] & 0x000F
occColumn[p + 1] = (eeData[24 + i] & 0x00F0) >> 4
occColumn[p + 2] = (eeData[24 + i] & 0x0F00) >> 8
occColumn[p + 3] = (eeData[24 + i] & 0xF000) >> 12
for i in range(32):
if occColumn[i] > 7:
occColumn[i] -= 16
for i in range(24):
for j in range(32):
p = 32 * i + j
self.offset[p] = (eeData[64 + p] & 0xFC00) >> 10
if self.offset[p] > 31:
self.offset[p] -= 64
self.offset[p] *= 1 << occRemScale
self.offset[p] += (
offsetRef
+ (occRow[i] << occRowScale)
+ (occColumn[j] << occColumnScale)
)
def _ExtractKtaPixelParameters(self): # pylint: disable=too-many-locals
# extract KtaPixel
KtaRC = [0] * 4
ktaTemp = [0] * 768
KtaRoCo = (eeData[54] & 0xFF00) >> 8
if KtaRoCo > 127:
KtaRoCo -= 256
KtaRC[0] = KtaRoCo
KtaReCo = eeData[54] & 0x00FF
if KtaReCo > 127:
KtaReCo -= 256
KtaRC[2] = KtaReCo
KtaRoCe = (eeData[55] & 0xFF00) >> 8
if KtaRoCe > 127:
KtaRoCe -= 256
KtaRC[1] = KtaRoCe
KtaReCe = eeData[55] & 0x00FF
if KtaReCe > 127:
KtaReCe -= 256
KtaRC[3] = KtaReCe
ktaScale1 = ((eeData[56] & 0x00F0) >> 4) + 8
ktaScale2 = eeData[56] & 0x000F
for i in range(24):
for j in range(32):
p = 32 * i + j
split = 2 * (p // 32 - (p // 64) * 2) + p % 2
ktaTemp[p] = (eeData[64 + p] & 0x000E) >> 1
if ktaTemp[p] > 3:
ktaTemp[p] -= 8
ktaTemp[p] *= 1 << ktaScale2
ktaTemp[p] += KtaRC[split]
ktaTemp[p] /= math.pow(2, ktaScale1)
# ktaTemp[p] = ktaTemp[p] * mlx90640->offset[p];
temp = abs(ktaTemp[0])
for kta in ktaTemp:
temp = max(temp, abs(kta))
ktaScale1 = 0
while temp < 64:
temp *= 2
ktaScale1 += 1
for i in range(768):
temp = ktaTemp[i] * math.pow(2, ktaScale1)
if temp < 0:
self.kta[i] = int(temp - 0.5)
else:
self.kta[i] = int(temp + 0.5)
self.ktaScale = ktaScale1
def _ExtractKvPixelParameters(self):
KvT = [0] * 4
kvTemp = [0] * 768
KvRoCo = (eeData[52] & 0xF000) >> 12
if KvRoCo > 7:
KvRoCo -= 16
KvT[0] = KvRoCo
KvReCo = (eeData[52] & 0x0F00) >> 8
if KvReCo > 7:
KvReCo -= 16
KvT[2] = KvReCo
KvRoCe = (eeData[52] & 0x00F0) >> 4
if KvRoCe > 7:
KvRoCe -= 16
KvT[1] = KvRoCe
KvReCe = eeData[52] & 0x000F
if KvReCe > 7:
KvReCe -= 16
KvT[3] = KvReCe
kvScale = (eeData[56] & 0x0F00) >> 8
for i in range(24):
for j in range(32):
p = 32 * i + j
split = 2 * (p // 32 - (p // 64) * 2) + p % 2
kvTemp[p] = KvT[split]
kvTemp[p] /= math.pow(2, kvScale)
# kvTemp[p] = kvTemp[p] * mlx90640->offset[p];
temp = abs(kvTemp[0])
for kv in kvTemp:
temp = max(temp, abs(kv))
kvScale = 0
while temp < 64:
temp *= 2
kvScale += 1
for i in range(768):
temp = kvTemp[i] * math.pow(2, kvScale)
if temp < 0:
self.kv[i] = int(temp - 0.5)
else:
self.kv[i] = int(temp + 0.5)
self.kvScale = kvScale
def _ExtractCILCParameters(self):
ilChessC = [0] * 3
self.calibrationModeEE = (eeData[10] & 0x0800) >> 4
self.calibrationModeEE = self.calibrationModeEE ^ 0x80
ilChessC[0] = eeData[53] & 0x003F
if ilChessC[0] > 31:
ilChessC[0] -= 64
ilChessC[0] /= 16.0
ilChessC[1] = (eeData[53] & 0x07C0) >> 6
if ilChessC[1] > 15:
ilChessC[1] -= 32
ilChessC[1] /= 2.0
ilChessC[2] = (eeData[53] & 0xF800) >> 11
if ilChessC[2] > 15:
ilChessC[2] -= 32
ilChessC[2] /= 8.0
self.ilChessC = ilChessC
def _ExtractDeviatingPixels(self):
self.brokenPixels = [0xFFFF] * 5
self.outlierPixels = [0xFFFF] * 5
pixCnt = 0
brokenPixCnt = 0
outlierPixCnt = 0
while (pixCnt < 768) and (brokenPixCnt < 5) and (outlierPixCnt < 5):
if eeData[pixCnt + 64] == 0:
self.brokenPixels[brokenPixCnt] = pixCnt
brokenPixCnt += 1
elif (eeData[pixCnt + 64] & 0x0001) != 0:
self.outlierPixels[outlierPixCnt] = pixCnt
outlierPixCnt += 1
pixCnt += 1
if brokenPixCnt > 4:
raise RuntimeError("More than 4 broken pixels")
if outlierPixCnt > 4:
raise RuntimeError("More than 4 outlier pixels")
if (brokenPixCnt + outlierPixCnt) > 4:
raise RuntimeError("More than 4 faulty pixels")
# print("Found %d broken pixels, %d outliers" % (brokenPixCnt, outlierPixCnt))
# TODO INCOMPLETE
def _I2CWriteWord(self, writeAddress, data):
cmd = bytearray(4)
cmd[0] = writeAddress >> 8
cmd[1] = writeAddress & 0x00FF
cmd[2] = data >> 8
cmd[3] = data & 0x00FF
dataCheck = [0]
with self.i2c_device as i2c:
i2c.write(cmd)
# print("Wrote:", [hex(i) for i in cmd])
time.sleep(0.001)
self._I2CReadWords(writeAddress, dataCheck)
# print("dataCheck: 0x%x" % dataCheck[0])
# if (dataCheck != data):
# return -2
_inbuf = bytearray(2 * I2C_READ_LEN)
def _I2CReadWords(self, addr, buffer, *, end=None):
# stamp = time.monotonic()
if end is None:
remainingWords = len(buffer)
else:
remainingWords = end
offset = 0
addrbuf = bytearray(2)
# inbuf = bytearray(2 * I2C_READ_LEN)
inbuf = self._inbuf
with self.i2c_device as i2c:
while remainingWords:
addrbuf[0] = addr >> 8 # MSB
addrbuf[1] = addr & 0xFF # LSB
read_words = min(remainingWords, I2C_READ_LEN)
i2c.write_then_readinto(
addrbuf, inbuf, in_end=read_words * 2
) # in bytes
# print("-> ", [hex(i) for i in addrbuf])
outwords = struct.unpack(
">" + "H" * read_words, inbuf[0 : read_words * 2]
)
# print("<- (", read_words, ")", [hex(i) for i in outwords])
for i, w in enumerate(outwords):
buffer[offset + i] = w
offset += read_words
remainingWords -= read_words
addr += read_words
ixc = None
mlx = None
frame = None
def init_camera(scl_pin=22, sda_pin=21, freq=100000):
"""Explicitly initializes the I2C bus and camera after power is stable."""
global ixc, mlx, frame
print(f"[Camera] Initializing I2C on SCL:{scl_pin}, SDA:{sda_pin} at {freq}Hz...")
ixc = I2C(pins=(scl_pin, sda_pin), frequency=freq)
print("[Camera] Probing for MLX90640...")
mlx = MLX90640(ixc)
# Bonus: Your wiki snapshot recommends 16Hz for smooth images!
mlx.refresh_rate = RefreshRate.REFRESH_16_HZ
frame = [0] * 768
print("[Camera] Setup successful!")
def read_temperature():
if mlx is None:
print("[Camera] Error: Camera not initialized. Call init_camera() first.")
return None
try:
print("Querying camera...")
mlx.getFrame(frame)
return frame
except Exception as e:
print(f"[Camera] Read error: {e}")
return None
@@ -0,0 +1,62 @@
import ustruct
class SensorBase:
def read16(self, register):
data = self.i2c.readfrom_mem(self.address, register, 2)
return ustruct.unpack('<H', data)[0]
def read_temp(self, register):
temp = self.read16(register);
# apply measurement resolution (0.02 degrees per LSB)
temp *= .02;
# Kelvin to Celcius
temp -= 273.15;
return temp;
def read_ambient_temp(self):
return self.read_temp(self._REGISTER_TA)
def read_object_temp(self):
return self.read_temp(self._REGISTER_TOBJ1)
def read_object2_temp(self):
if self.dual_zone:
return self.read_temp(self._REGISTER_TOBJ2)
else:
raise RuntimeError("Device only has one thermopile")
@property
def ambient_temp(self):
return self.read_ambient_temp()
@property
def object_temp(self):
return self.read_object_temp()
@property
def object2_temp(self):
return self.read_object2_temp()
class MLX90614(SensorBase):
_REGISTER_TA = 0x06
_REGISTER_TOBJ1 = 0x07
_REGISTER_TOBJ2 = 0x08
def __init__(self, i2c, address=0x5a):
self.i2c = i2c
self.address = address
_config1 = i2c.readfrom_mem(address, 0x25, 2)
_dz = ustruct.unpack('<H', _config1)[0] & (1<<6)
self.dual_zone = True if _dz else False
class MLX90615(SensorBase):
_REGISTER_TA = 0x26
_REGISTER_TOBJ1 = 0x27
def __init__(self, i2c, address=0x5b):
self.i2c = i2c
self.address = address
self.dual_zone = False
+1
View File
@@ -0,0 +1 @@
db.sqlite*
Binary file not shown.
Binary file not shown.
Binary file not shown.
+311 -145
View File
@@ -1,53 +1,53 @@
import base64
import json import json
import threading
import queue
import time import time
import traceback import traceback
import asyncio
import requests
from orchestrateur.sensors import gps from orchestrateur.sensors import gps
from shared import get_lora, get_mqtt_client, deviceTypes, config, payloads from shared import get_lora, get_mqtt_client, deviceTypes, config, payloads
from shared.logging import log from shared.logging import log
from shared.cookingState import CookingStates
from shared.lora_device import LoraCommands
from sensors import ultrasonicRanger, temp_hum, button, camera from sensors import ultrasonicRanger, temp_hum, button, camera
# --- Read Unique Device ID --- # --- Read Unique Device ID ---
try: def get_device_id():
with open("device_id.txt", "r") as f: for path in ["device_id.txt", "/home/pi/SmartWave/orchestrateur/device_id.txt"]:
DEVICE_ID = f.read().strip() try:
except Exception: with open(path, "r") as f:
try: return f.read().strip()
with open("/home/pi/SmartWave/orchestrateur/device_id.txt", "r") as f: except Exception:
DEVICE_ID = f.read().strip() pass
except Exception: return "RPI_Orchestrateur_Default"
DEVICE_ID = "RPI_Orchestrateur_Default"
# Thread-safe queue for application messages DEVICE_ID = get_device_id()
data_queue = queue.Queue()
# --- STATE MACHINE DEFINITIONS ---
class MicrowaveState:
IDLE = "IDLE" # Microwave is empty
ANALYZING = "ANALYZING" # Reading sensors & waiting for IR
WAITING_FOR_CLOUD = "WAITING_FOR_CLOUD" # Waiting for API parameters
COOKING = "COOKING" # Microwave is active
DONE = "DONE" # Finished/Stopped, waiting for dish removal
# Global state trackers
microwave_states = {"2": MicrowaveState.IDLE}
button_state = False
async_event_queue = None
# Async synchronization trackers for MQTT IR sensors responses
ir_data_cache = {} # mw_id -> dict of IR readings
ir_data_events = {} # mw_id -> asyncio.Event()
# --- HARDWARE SETUP ---
lora = get_lora() lora = get_lora()
lora.configure() lora.configure()
def lora_listener():
"""Background Thread: Listens to LoRa traffic and responds to Heartbeats."""
print("Thread Écouteur LoRa démarré.")
while True:
paquet = lora.receive_packet(timeout_ms=1000)
if paquet:
donnees = paquet["data"]
expediteur_type = donnees.get("type")
if expediteur_type == deviceTypes.DEVICE_TYPES["MICROWAVE"]:
print(f"\n[Thread LoRa] Heartbeat reçu de {donnees.get('id')}")
reponse = {
"id": DEVICE_ID,
"type": deviceTypes.DEVICE_TYPES["ORCHESTRATOR"]
}
lora.send(reponse)
else:
data_queue.put({"source": "LoRa", "data": paquet})
# --- Setup & Connect MQTT ---
mqtt_client = get_mqtt_client( mqtt_client = get_mqtt_client(
host="192.168.50.1", # Using explicit gateway IP to dodge Docker loopback blocks host="192.168.50.1",
client_id="smartwave-orchestrateur-"+DEVICE_ID, client_id="smartwave-orchestrateur-" + DEVICE_ID,
use_tls=config.USE_TLS, use_tls=config.USE_TLS,
cafile="/home/pi/SmartWave/orchestrateur/mqtt/certs/ca.crt", cafile="/home/pi/SmartWave/orchestrateur/mqtt/certs/ca.crt",
keepalive=config.MQTT_KEEPALIVE, keepalive=config.MQTT_KEEPALIVE,
@@ -55,139 +55,305 @@ mqtt_client = get_mqtt_client(
mqtt_client.connect() mqtt_client.connect()
mqtt_client.subscribe(config.MQTT_TOPIC_SENSOR, qos=config.MQTT_QOS) mqtt_client.subscribe(config.MQTT_TOPIC_SENSOR, qos=config.MQTT_QOS)
mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS) mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS)
print(f"Subscribed to topic: {config.MQTT_TOPIC_SENSOR}")
# --- THE CRUCIAL PAHO FIX ---
# Start Paho's internal background thread. This handles all network packets,
# automatic keepalive pings, and delivery receipts cleanly.
if hasattr(mqtt_client._client, "loop_start"): if hasattr(mqtt_client._client, "loop_start"):
mqtt_client._client.loop_start() mqtt_client._client.loop_start()
print("Paho MQTT asynchronous network loop started.") print("[MQTT] Paho background loop started.")
# --- BACKGROUND TASKS (PRODUCERS) ---
async def lora_listener_task():
"""Polls LoRa and pushes to the async queue."""
print("[LoRa] Async listener started.")
while True:
# Run blocking lora receive in a thread to not block asyncio loop
paquet = await asyncio.to_thread(lora.receive_reliable, timeout_ms=100)
if paquet:
await async_event_queue.put({"source": "LoRa", "data": paquet})
await asyncio.sleep(0.05)
def mqtt_listener(): async def mqtt_listener_task():
"""Background Thread: Constantly inspects incoming MQTT message cache.""" """Polls MQTT cache and pushes to the async queue."""
print("Thread MQTT démarré.") print("[MQTT] Async listener started.")
while True: while True:
message = mqtt_client.get_message() message = mqtt_client.get_message()
if message: if message:
# Try to parse the payload as a python dictionary, but if it fails, just print the raw payload
try: try:
payload = json.loads(message['payload']) payload = json.loads(message['payload'])
except Exception as e: except Exception:
print(f"Error parsing MQTT payload: {e}") payload = message['payload']
payload = message['payload'] # Fallback to raw payload if parsing fails
print(f"\n[Thread MQTT] Message reçu : {message}") # --- SAFE TOPIC DECODING ---
data_queue.put({"source": "MQTT", "topic": message['topic'] ,"data": payload}) topic = message['topic']
if isinstance(topic, bytes):
# Sleep for 100ms. Prevents the thread from turning into an infinite 100% CPU hog. topic = topic.decode('utf-8')
time.sleep(0.2)
await async_event_queue.put({
"source": "MQTT",
"topic": topic,
"data": payload
})
await asyncio.sleep(0.1)
# Button
button_state = False
def button_callback(): def button_callback():
"""Button physical interrupt callback."""
global button_state global button_state
button_state = not button_state if microwave_states.get("2") == MicrowaveState.COOKING:
print(f"\n[Thread Button] Button state changed to: {button_state}") print("[Button] Toggling pause/resume for microwave '2'.")
lora.send_reliable({"id": DEVICE_ID, "microwave_id": "2", "action": LoraCommands.TOGGLE_PAUSE})
button.set_callback(button_callback) else:
button_state = not button_state
print(f"[Button] Defrost state toggled to: {button_state}")
# Launch background monitoring workers button.set_callback(button_callback)
# threading.Thread(target=lora_listener, daemon=True).start()
# threading.Thread(target=mqtt_listener, daemon=True).start()
# Launch button monitoring thread
button.start_button_monitoring_thread() button.start_button_monitoring_thread()
print("Orchestrateur prêt. Le main loop est libre.") # --- HARDWARE CONTROLLERS ---
def _stop_hardware(microwave_id: str):
print(f"[{microwave_id}] /!\ Emergency stop issued to hardware.")
# TODO: Add LoRa STOP command here
# Sensor reading # --- ASYNC COOKING LOGIC ---
def read_sensors(): def read_local_sensors(microwave_id, initial_dish_height):
"""Read all sensors and return a dictionary of their values.""" """Blocking function to read local I2C/SPI sensors. Runs in a thread."""
log("\nLecture des capteurs...") print(f"[{microwave_id}] Reading local physical sensors...")
sensor_data = {} sensor_data = {
"microwave_id": microwave_id,
"defrost_mode": button_state,
"ultrasonic_distance": initial_dish_height # Reuse height from trigger
}
# Read Ultrasonic Ranger # Temp / Hum (handles DHT error safely)
distance = ultrasonicRanger.get_dish_height() try:
if distance is not None: temp, hum = temp_hum.get_temperature_and_humidity_with_retry()
log(f"\nLecture du capteur Ultrason : {distance}") if temp is not None:
sensor_data["ultrasonic_distance"] = distance sensor_data["temperature"] = temp
sensor_data["humidity"] = hum
# Read Temperature and Humidity except Exception as e:
temperature, humidity = temp_hum.get_temperature_and_humidity() log(f"[{microwave_id}] DHT read warning: {e}")
if temperature is not None and humidity is not None:
log(f"\nLecture du capteur Temp/Hum : {temperature}, {humidity}")
sensor_data["temperature"] = temperature
sensor_data["humidity"] = humidity
# Read GPS Data
gps_data = gps.get_gps_data()
if gps_data:
log(f"\nLecture du capteur GPS : {gps_data}")
sensor_data["gps"] = gps_data
# Camera # Camera
picture_bytes = None
try: try:
picture_bytes = camera.get_picture() sensor_data["camera_image"] = camera.get_picture()
log(f"\nLecture du capteur Caméra : {len(picture_bytes)} bytes")
sensor_data["camera_image"] = picture_bytes
except Exception as e: except Exception as e:
log(f"Error reading camera data: {e}") log(f"[{microwave_id}] Camera read failed: {e}")
# Read Button State (last because he can still change state while reading other sensors)
sensor_data["button_state"] = button_state
return sensor_data return sensor_data
# --- MAIN EXECUTION LOOP ---
while True:
try:
# Check for non-heartbeat data
try:
msg = data_queue.get(block=False)
# print(msg)
if msg["source"] == "LoRa":
print(f"\n[Main Loop] LoRa : Données traitées : {msg['data']}")
elif msg["source"] == "MQTT":
if (msg["topic"] == config.MQTT_TOPIC_HELLO.decode('utf-8')):
if ("id_orchestrator" in msg["data"] and msg["data"]["id_orchestrator"] == DEVICE_ID):
# Do not answer to messages coming from me
continue
microwave_id = msg["data"]["id_microwave"]
print(f"\n[Main Loop] MQTT : Hello reçu de {microwave_id}.")
# Responds
mqtt_client.publish(config.MQTT_TOPIC_HELLO, payloads.mqtt_hello_ack(DEVICE_ID, microwave_id), qos=config.MQTT_QOS)
print(f"[Main Loop] MQTT : Réponse Hello envoyée à {microwave_id}.")
# TODO : Save in database
print(f"\n[Main Loop] MQTT : Données traitées : {msg['data']}")
except queue.Empty:
pass
# DEBUG : Read sensors
sensor_values = read_sensors()
if sensor_values:
sensor_values_print = sensor_values.copy()
if "camera_image" in sensor_values_print:
sensor_values_print["camera_image"] = f"<{len(sensor_values_print['camera_image'])} bytes>"
print(f"\nCapteurs Données lues : {sensor_values_print}")
time.sleep(3)
except KeyboardInterrupt:
break
except Exception as e:
traceback.print_exc()
time.sleep(1) # Prevents rapid error logging in case of persistent issues
# Clean termination async def handle_new_dish(microwave_id, detected_height):
if hasattr(mqtt_client._client, "loop_stop"): """Triggered when a new dish is placed inside."""
mqtt_client._client.loop_stop() microwave_states[microwave_id] = MicrowaveState.ANALYZING
mqtt_client.close() print(f"\n[{microwave_id}] 🍽️ Dish detected at {detected_height:.1f} cm! Requesting IR from microwave...")
# 1. Setup synchronization event and clear previous cache for this microwave
event = asyncio.Event()
ir_data_events[microwave_id] = event
ir_data_cache.pop(microwave_id, None)
# 2. Send IR request to ESP32 via MQTT immediately
mqtt_client.publish(
config.MQTT_TOPIC_COOKING,
payloads.mqtt_cooking_init(microwave_id),
qos=config.MQTT_QOS
)
# 3. Start local sensor reading in parallel
sensor_task = asyncio.create_task(asyncio.to_thread(read_local_sensors, microwave_id, detected_height))
# 4. Wait for local sensors to finish reading
sensors_data = await sensor_task
# Check if dish was removed while reading sensors
if microwave_states.get(microwave_id) != MicrowaveState.ANALYZING:
print(f"[{microwave_id}] Dish removed during sensor read. Aborting.")
ir_data_events.pop(microwave_id, None)
return
# 5. Wait for MQTT IR data (if it already arrived, event.wait() returns instantly)
try:
await asyncio.wait_for(event.wait(), timeout=10.0)
ir_payload = ir_data_cache.get(microwave_id, {})
sensors_data["ir_initial_temp"] = ir_payload.get("dish_temp")
sensors_data["ir_ambient_temp"] = ir_payload.get("ambient_temp")
print(f"[{microwave_id}] IR data synchronized successfully: {ir_payload}")
except asyncio.TimeoutError:
print(f"[{microwave_id}] ⚠️ Timeout waiting for MQTT IR data from ESP32.")
sensors_data["ir_initial_temp"] = None
sensors_data["ir_ambient_temp"] = None
finally:
ir_data_events.pop(microwave_id, None)
# 6. Dispatch cloud request task
asyncio.create_task(request_cloud_cooking_plan(microwave_id, sensors_data))
async def request_cloud_cooking_plan(microwave_id, sensors_data):
"""Sends all data to the cloud and starts the microwave if successful."""
microwave_states[microwave_id] = MicrowaveState.WAITING_FOR_CLOUD
URL = "https://smartwave.matthiasg.dev/cooking-params"
# Format image
if isinstance(sensors_data.get("camera_image"), bytes):
sensors_data["camera_image"] = base64.b64encode(sensors_data["camera_image"]).decode("utf-8")
print(f"[{microwave_id}] Requesting cooking plan from cloud app...")
try:
response = await asyncio.to_thread(requests.post, URL, json=sensors_data, timeout=30)
# Abort if state changed (e.g. user removed dish while waiting for wifi)
if microwave_states[microwave_id] != MicrowaveState.WAITING_FOR_CLOUD:
print(f"[{microwave_id}] Dish removed during API request. Discarding API plan.")
return
response.raise_for_status()
plan = response.json().get("cook_plan", {})
c_time = plan.get("cook_time_seconds")
c_power = plan.get("effective_power_watts")
c_temp = plan.get("target_temp")
if c_time is None or c_power is None or c_temp is None:
print(f"[{microwave_id}] ❌ Invalid plan received: {response.json()}")
microwave_states[microwave_id] = MicrowaveState.DONE # Fail safe
return
print(f"[{microwave_id}] Cloud Plan Received! Starting microwave: {c_time}s @ {c_power}W")
microwave_states[microwave_id] = MicrowaveState.COOKING
mqtt_client.publish(
config.MQTT_TOPIC_COOKING,
payloads.mqtt_cooking_config(microwave_id, c_time, c_power, c_temp),
qos=config.MQTT_QOS
)
except Exception as e:
print(f"[{microwave_id}] Cloud API Error: {e}")
microwave_states[microwave_id] = MicrowaveState.DONE
# --- MAIN LOGIC TASKS ---
async def process_messages_task():
"""Consumes the unified event queue."""
while True:
msg = await async_event_queue.get()
source = msg["source"]
data = msg["data"]
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}")
if n_state == CookingStates.IDLE and microwave_states.get(mw_id) == MicrowaveState.COOKING:
microwave_states[mw_id] = MicrowaveState.DONE
print(f"[{mw_id}] Cooking finished. Waiting for user to remove dish.")
elif source == "MQTT":
topic = msg["topic"]
# Helper to normalize config topics to str
def to_str(val):
return val.decode('utf-8') if isinstance(val, bytes) else val
hello_topic = to_str(config.MQTT_TOPIC_HELLO)
sensor_topic = to_str(config.MQTT_TOPIC_SENSOR)
if topic == hello_topic:
if data.get("id_orchestrator") != DEVICE_ID:
mw_id = data.get("id_microwave")
print(f"[MQTT] Hello from {mw_id}. Sending ACK.")
mqtt_client.publish(
config.MQTT_TOPIC_HELLO,
payloads.mqtt_hello_ack(DEVICE_ID, mw_id),
qos=config.MQTT_QOS
)
elif topic == sensor_topic:
mw_id = str(data.get("id_microwave"))
print(f"[MQTT] Sensor data received for microwave {mw_id}: {data}")
# Store IR data and notify the waiting dish handler
ir_data_cache[mw_id] = data
if mw_id in ir_data_events:
ir_data_events[mw_id].set()
async def get_filtered_dish_height(samples=3, delay=0.04):
"""Reads ultrasonic sensor multiple times and returns the median, discarding invalid zeros."""
valid_samples = []
for _ in range(samples):
h = await asyncio.to_thread(ultrasonicRanger.get_dish_height)
# Discard 0.0 or near-zero timeout glitches
if h is not None and h > 0.5:
valid_samples.append(h)
await asyncio.sleep(delay)
if valid_samples:
valid_samples.sort()
return valid_samples[len(valid_samples) // 2] # Median sample
return None # All reads failed or out of range
async def monitor_dish_height_task():
"""Monitors presence of dish with hysteresis and debouncing."""
mw_id = "2"
consecutive_present = 0
consecutive_absent = 0
REQUIRED_STABLE_READS = 3 # Must see 3 stable states in a row (~1 second)
while True:
dist = await get_filtered_dish_height()
current_state = microwave_states.get(mw_id, MicrowaveState.IDLE)
if dist is not None:
# Hysteresis Thresholds:
# - Must be > 2.5 cm to detect dish insertion
# - Must be < 1.2 cm to detect dish removal
if dist > 2.5:
consecutive_present += 1
consecutive_absent = 0
elif dist < 1.2:
consecutive_absent += 1
consecutive_present = 0
else:
# Dead-zone (1.2cm to 2.5cm) -> Noise buffer
consecutive_present = 0
consecutive_absent = 0
# --- DISH INSERTED CONFIRMED ---
if consecutive_present >= REQUIRED_STABLE_READS and current_state == MicrowaveState.IDLE:
consecutive_present = 0
asyncio.create_task(handle_new_dish(mw_id, dist))
# --- DISH REMOVED CONFIRMED ---
elif consecutive_absent >= REQUIRED_STABLE_READS and current_state != MicrowaveState.IDLE:
consecutive_absent = 0
print(f"\n[{mw_id}] Dish Removed! Resetting state to IDLE.")
microwave_states[mw_id] = MicrowaveState.IDLE
if current_state == MicrowaveState.COOKING:
_stop_hardware(mw_id)
# Remove from IR cache and events
ir_data_cache.pop(mw_id, None)
ir_data_events.pop(mw_id, None)
await asyncio.sleep(0.3)
# --- BOOTSTRAP ---
async def main():
global async_event_queue
print("🚀 Orchestrateur Asyncio prêt. Lancement des tâches...")
async_event_queue = asyncio.Queue()
await asyncio.gather(
lora_listener_task(),
mqtt_listener_task(),
process_messages_task(),
monitor_dish_height_task()
)
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\nArrêt manuel.")
finally:
if hasattr(mqtt_client._client, "loop_stop"):
mqtt_client._client.loop_stop()
mqtt_client.close()
+7 -3
View File
@@ -11,7 +11,8 @@ grovepi.pinMode(button, "INPUT")
button_callback = None button_callback = None
def read_button_state(): def read_button_state():
if not grove_lock.acquire(timeout=0.05): # 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 return None
try: try:
return grovepi.digitalRead(button) return grovepi.digitalRead(button)
@@ -26,15 +27,18 @@ def monitor_button():
last_button_state = button_switch_state last_button_state = button_switch_state
while True: while True:
time.sleep(0.04)
current_state = read_button_state() current_state = read_button_state()
if current_state is not None: if current_state is not None:
# Rising edge detection (0 -> 1 transition)
if current_state == 1 and last_button_state == 0: if current_state == 1 and last_button_state == 0:
if button_callback: if button_callback:
button_callback() button_callback()
last_button_state = current_state last_button_state = current_state
time.sleep(0.02) # Fast 20ms poll when lock is clear
else:
# Lock was busy; retry quickly without updating last_button_state
time.sleep(0.01)
def start_button_monitoring_thread(): def start_button_monitoring_thread():
threading.Thread(target=monitor_button, daemon=True).start() threading.Thread(target=monitor_button, daemon=True).start()
@@ -15,8 +15,9 @@
import time,sys import time,sys
import RPi.GPIO as GPIO import RPi.GPIO as GPIO
import smbus import smbus
from shared import config
debug = 0 debug = config.DEBUG
# use the bus that matches your raspi version # use the bus that matches your raspi version
rev = GPIO.RPI_REVISION rev = GPIO.RPI_REVISION
if rev == 2 or rev == 3: if rev == 2 or rev == 3:
+9
View File
@@ -1,5 +1,6 @@
import grovepi import grovepi
import math import math
import time
from sensors.lock import grove_lock from sensors.lock import grove_lock
# Connect the Grove Temperature & Humidity Sensor Pro to digital port D3 # Connect the Grove Temperature & Humidity Sensor Pro to digital port D3
@@ -20,3 +21,11 @@ def get_temperature_and_humidity():
else: else:
print("Error reading from DHT sensor") print("Error reading from DHT sensor")
return None, None return None, None
def get_temperature_and_humidity_with_retry(max_retries=3):
for _ in range(max_retries): # Try up to max_retries times
temp, humidity = get_temperature_and_humidity()
if temp is not None and humidity is not None:
return temp, humidity
time.sleep(1) # Wait a bit before retrying
return None, None
+1 -1
View File
@@ -7,7 +7,7 @@ lora.configure()
print("Raspberry Pi : En attente active de JSON...") print("Raspberry Pi : En attente active de JSON...")
while True: while True:
paquet = lora.receive_packet(timeout_ms=5000) paquet = lora.receive_reliable(timeout_ms=5000)
if paquet: if paquet:
# Plus besoin de décoder du HEX ou de parser du JSON manuellement ! # Plus besoin de décoder du HEX ou de parser du JSON manuellement !
groupe = paquet['group'] groupe = paquet['group']
+11
View File
@@ -4,6 +4,17 @@
import shared.deviceTypes as deviceTypes import shared.deviceTypes as deviceTypes
import shared.config as config import shared.config as config
import shared.payloads as payloads import shared.payloads as payloads
import shared.cookingState as cookingState
import shared.safeQueue as safeQueue
try:
import shared.lora_device as lora_device
except ImportError:
pass # No need
try:
import shared.uart_comm as uart_comm
except ImportError:
pass # No need as we are on the RPI
import shared.sensors
def get_lora(*args, **kwargs): def get_lora(*args, **kwargs):
from .lora_device import get_lora_device from .lora_device import get_lora_device
+1 -1
View File
@@ -1,7 +1,7 @@
DEBUG=True DEBUG=True
# LoRa # LoRa
HEARTBEAT_INTERVAL = 30 LORA_HEARTBEAT_INTERVAL = 30
# MQTT # MQTT
MQTT_BROKER_HOST = "192.168.50.1" MQTT_BROKER_HOST = "192.168.50.1"
+217
View File
@@ -0,0 +1,217 @@
import time
class CookingState:
TEMPERATURE_TOLERANCE = 1.0
def __init__(self, cook_time: int, power_level: int, target_temp: float, temperature_provider=None, on_state_change=None, on_refresh=None):
self.cook_time = cook_time
self.power_level = power_level
self.target_temp = target_temp
self.start_time = time.time()
self.temperature_provider = temperature_provider
self.on_state_change = on_state_change
self.on_refresh = on_refresh
self.on_pause = None
self.state = CookingStates.COOKING
self.paused = False
self._pause_started_at = None
self._paused_duration = 0.0
self.current_dish_temp = None
self.current_ambient_temp = None
self.estimated_remaining_time = float(cook_time)
self._last_temperature_sample = None
self._last_refresh_signature = None
self._stirred = False
def set_temperature_provider(self, temperature_provider):
self.temperature_provider = temperature_provider
def set_state_change_callback(self, callback):
self.on_state_change = callback
def set_refresh_callback(self, callback):
self.on_refresh = callback
def set_pause_callback(self, callback):
self.on_pause = callback
def pause(self):
if self.paused:
return
self.paused = True
self._pause_started_at = time.time()
# self._notify_refresh(force=True)
if self.on_pause:
self.on_pause(self)
def unpause(self):
if not self.paused:
return
now = time.time()
if self._pause_started_at is not None:
self._paused_duration += now - self._pause_started_at
# self._pause_started_at = None
self.paused = False
# self._notify_refresh(force=True)
def toggle_pause(self):
if self.paused:
self.unpause()
else:
self.pause()
self.on_pause(self)
def set_state(self, state):
if self.state == state:
return
self.state = state
self._notify_state_change()
self._notify_refresh(force=True)
def get_elapsed_time(self) -> float:
now = time.time()
elapsed = now - self.start_time - self._paused_duration
if self.paused and self._pause_started_at is not None:
elapsed -= now - self._pause_started_at
return max(0.0, elapsed)
def get_remaining_time(self) -> int:
"""Returns the estimated remaining cooking time in seconds."""
return int(max(0.0, self.get_remaining_time_estimation()))
def get_remaining_time_estimation(self) -> float:
elapsed_time = self.get_elapsed_time()
timer_remaining = max(0.0, float(self.cook_time) - elapsed_time)
if self.current_dish_temp is None:
return timer_remaining
if self.current_dish_temp >= self.target_temp:
return timer_remaining
heating_rate = self._estimate_heating_rate()
if heating_rate <= 0:
return timer_remaining
target_remaining = (self.target_temp - self.current_dish_temp) / heating_rate
return max(timer_remaining, max(0.0, target_remaining))
def _read_temperatures(self):
if self.temperature_provider is None:
return None, None
temperatures = self.temperature_provider()
if temperatures is None:
return None, None
if isinstance(temperatures, (list, tuple)) and len(temperatures) >= 2:
return temperatures[0], temperatures[1]
raise ValueError("temperature_provider must return a pair: (dish_temp, ambient_temp)")
def _estimate_heating_rate(self):
if self._last_temperature_sample is None:
return 0.0
last_time, last_temp = self._last_temperature_sample
now = time.time()
current_temp = self.current_dish_temp
if current_temp is None:
return 0.0
delta_time = now - last_time
if delta_time <= 0:
return 0.0
return (current_temp - last_temp) / delta_time
def _notify_state_change(self):
if self.on_state_change is None:
return
self.on_state_change(self)
def _notify_refresh(self, force=False):
if self.on_refresh is None:
return
signature = (
int(self.get_elapsed_time()),
int(self.get_remaining_time_estimation()),
self.current_dish_temp,
self.current_ambient_temp,
self.state,
self.paused,
)
if not force and signature == self._last_refresh_signature:
return
self._last_refresh_signature = signature
self.on_refresh(self)
def update_tick(self):
if self.state == CookingStates.IDLE:
return self.state
if self.paused:
self._notify_refresh()
return self.state
previous_state = self.state
previous_temperature = self.current_dish_temp
try:
self.current_dish_temp, self.current_ambient_temp = self._read_temperatures()
except Exception:
self.current_dish_temp = previous_temperature
now = time.time()
elapsed_time = self.get_elapsed_time()
self.estimated_remaining_time = self.get_remaining_time_estimation()
print(elapsed_time, self.cook_time, self.current_dish_temp, self.target_temp, self._paused_duration, self._pause_started_at, now)
if self.current_dish_temp is not None:
if elapsed_time < (self.cook_time / 2.0) and self.current_dish_temp >= self.target_temp and (self._paused_duration == None or self._paused_duration < 5): # If the dish is heating too fast, we require stirring
self.state = CookingStates.STIRRING_REQUIRED
self.pause()
elif elapsed_time >= self.cook_time and self.current_dish_temp >= (self.target_temp - self.TEMPERATURE_TOLERANCE):
self.state = CookingStates.DONE
elif elapsed_time >= self.cook_time * 1.25 and (self._paused_duration == None or self._paused_duration < 5): # If the dish is not heating up
self.state = CookingStates.STIRRING_REQUIRED
self.pause()
elif self._pause_started_at != None and (self._pause_started_at + self._paused_duration) < (now - (self.cook_time * 0.75)): # If the dish had to be pause and it's been a long time, we stop the cooking
self.state = CookingStates.DONE
self._last_temperature_sample = (now, self.current_dish_temp)
if self.state != previous_state:
self._notify_state_change()
self._notify_refresh()
return self.state
class CookingStates:
COOKING = 0
STIRRING_REQUIRED = 1
DONE = 2
ALERT = 3 # Microwave is too hot internally or other alerts
IDLE = 4 # Waiting for cooking parameters to be set, or after cooking is done
@staticmethod
def get_state_name(state_val):
for key, value in CookingStates.__dict__.items():
if value == state_val and not key.startswith('__'):
return key
return "UNKNOWN"
+308 -57
View File
@@ -1,5 +1,6 @@
import sys import sys
import time import time
import random
IS_MICROPYTHON = sys.implementation.name == 'micropython' IS_MICROPYTHON = sys.implementation.name == 'micropython'
@@ -8,49 +9,253 @@ if IS_MICROPYTHON:
from machine import Pin, SPI from machine import Pin, SPI
import ubinascii import ubinascii
import ujson as json import ujson as json
else:
import threading
import serial
import json
# --- BASE RELIABLE LORA DEVICE ---
class BaseLoraDevice:
"""Base class providing automatic ACK generation, retries, and duplicate filtering."""
def __init__(self):
self.processed_msg_ids = set()
self.received_acks = set()
self.pending_rx_queue = []
self.default_group = 2
def _generate_msg_id(self):
return random.getrandbits(16)
def _send_ack(self, ack_id):
"""Sends an immediate acknowledgement packet back to the sender."""
print(f"[ReliableLoRa] -> Triggering ACK send for msg_id: {ack_id}")
if IS_MICROPYTHON:
time.sleep_ms(10)
else:
time.sleep(0.01)
ack_payload = {"_type": "_ack", "_ack_id": ack_id}
self.send(ack_payload)
def _process_incoming_packet(self, packet):
"""Internal packet processor: handles ACKs and deduplication."""
if not packet or packet.get("raw"):
return packet
data = packet.get("data")
if isinstance(data, dict):
# 1. Handle incoming ACK response
if data.get("_type") == "_ack":
ack_id = data.get("_ack_id")
print(f"[ReliableLoRa] <- SUCCESSFULLY MATCHED ACK ID: {ack_id}")
if ack_id is not None:
self.received_acks.add(ack_id)
if len(self.received_acks) > 100:
self.received_acks.clear()
return None # Drop internal protocol message from user queue
# 2. Handle incoming command expecting an ACK
msg_id = data.get("_msg_id")
if msg_id is not None:
print(f"[ReliableLoRa] <- Received packet with msg_id {msg_id}. Queuing ACK.")
self._send_ack(msg_id)
if msg_id in self.processed_msg_ids:
print(f"[ReliableLoRa] Discarding duplicate retry for msg_id {msg_id}")
return None # Discard duplicate retry
self.processed_msg_ids.add(msg_id)
if len(self.processed_msg_ids) > 100:
self.processed_msg_ids.clear()
return packet
def send_reliable(self, payload, max_retries=4, ack_timeout=2.5):
"""Sends a payload and retries until an ACK is received or max retries are reached."""
lock = getattr(self, 'lock', None)
if isinstance(payload, dict):
payload = dict(payload)
else:
payload = {"data": payload}
msg_id = self._generate_msg_id()
payload["_msg_id"] = msg_id
print(f"\n[ReliableLoRa] === Starting send_reliable for msg_id {msg_id} ===")
for attempt in range(max_retries):
print(f"[ReliableLoRa] Attempt {attempt + 1}/{max_retries} transmitting msg_id {msg_id}")
self.send(payload)
start_time = time.time()
while (time.time() - start_time) < ack_timeout:
if lock: lock.acquire()
try:
if msg_id in self.received_acks:
self.received_acks.remove(msg_id)
print(f"[ReliableLoRa] === ACK received for msg_id {msg_id} on attempt {attempt + 1} ===")
return True
finally:
if lock: lock.release()
packet = self.receive_packet(timeout_ms=500)
if packet:
print(f"[ReliableLoRa] Received raw packet while waiting for ACK: {packet}")
if lock: lock.acquire()
try:
filtered_packet = self._process_incoming_packet(packet)
if filtered_packet:
self.pending_rx_queue.append(filtered_packet)
finally:
if lock: lock.release()
if lock: lock.acquire()
try:
if msg_id in self.received_acks:
self.received_acks.remove(msg_id)
print(f"[ReliableLoRa] === ACK received for msg_id {msg_id} after poll ===")
return True
finally:
if lock: lock.release()
print(f"[ReliableLoRa] Attempt {attempt + 1} timed out waiting for ACK for msg_id {msg_id}")
print(f"[ReliableLoRa] ERROR: Failed to receive ACK for msg_id {msg_id} after {max_retries} attempts.")
return False
def receive_reliable(self, timeout_ms=1000):
"""Receives a packet, automatically sending ACKs and filtering duplicate retries."""
if len(self.pending_rx_queue) > 0:
return self.pending_rx_queue.pop(0)
start_time = time.time()
timeout_s = timeout_ms / 1000.0
while True:
elapsed = time.time() - start_time
remaining_ms = int((timeout_s - elapsed) * 1000)
if remaining_ms <= 0:
break
poll_time = max(50, min(remaining_ms, 300))
packet = self.receive_packet(timeout_ms=poll_time)
if packet:
filtered_packet = self._process_incoming_packet(packet)
if filtered_packet:
return filtered_packet
return None
if IS_MICROPYTHON:
# --- PILOTE SPI DIRECT (ESP32 / Heltec V3) --- # --- PILOTE SPI DIRECT (ESP32 / Heltec V3) ---
class LoraHardwareSPI: class LoraHardwareSPI(BaseLoraDevice):
def __init__(self, spi_bus=1, clk=9, mosi=10, miso=11, cs=8, irq=14, rst=12, gpio=13): def __init__(self, spi_bus=1, clk=9, mosi=10, miso=11, cs=8, irq=14, rst=12, gpio=13):
from sx1262 import SX1262 super().__init__()
self.lora = SX1262( self._pins = {
spi_bus=spi_bus, clk=clk, mosi=mosi, miso=miso, "spi_bus": spi_bus, "clk": clk, "mosi": mosi, "miso": miso,
cs=cs, irq=irq, rst=rst, gpio=gpio "cs": cs, "irq": irq, "rst": rst, "gpio": gpio
) }
self.default_group = 2 # On définit le groupe par défaut ici self._cfg = {"freq": 868.1, "bw": 125.0, "sf": 7, "cr": 5, "power": 14}
self.lock = _thread.allocate_lock() # Création du verrou self.lock = _thread.allocate_lock()
self.lora = None
self.reset_hardware()
def reset_hardware(self):
"""Resets SX1262 hardware and recreates driver instance."""
with self.lock:
try:
irq_pin = Pin(self._pins["irq"], Pin.IN)
irq_pin.irq(handler=None)
except Exception:
pass
try:
rst_pin = Pin(self._pins["rst"], Pin.OUT)
rst_pin.value(0)
time.sleep_ms(30)
rst_pin.value(1)
time.sleep_ms(50)
except Exception:
pass
self.lora = None
time.sleep_ms(50)
try:
from sx1262 import SX1262
new_instance = SX1262(**self._pins)
new_instance.begin(
freq=self._cfg["freq"], bw=self._cfg["bw"], sf=self._cfg["sf"],
cr=self._cfg["cr"], power=self._cfg["power"],
useRegulatorLDO=False, crcOn=True, preambleLength=8, implicit=False
)
# SyncWord 0x12 = Decimal 18
new_instance.setSyncWord(0x12)
self.lora = new_instance
except Exception as e:
print(f"[LoRa SPI] Initialization error: {e}")
def configure(self, freq=868.1, bw=125.0, sf=7, cr=5, power=14): def configure(self, freq=868.1, bw=125.0, sf=7, cr=5, power=14):
self.lora.begin( self._cfg = {"freq": freq, "bw": bw, "sf": sf, "cr": cr, "power": power}
freq=freq, bw=bw, sf=sf, cr=cr, power=power, if self.lora is None:
useRegulatorLDO=False, crcOn=True, preambleLength=8, implicit=False self.reset_hardware()
) else:
self.lora.setSyncWord(0x14) with self.lock:
try:
self.lora.begin(
freq=freq, bw=bw, sf=sf, cr=cr, power=power,
useRegulatorLDO=False, crcOn=True, preambleLength=8, implicit=False
)
self.lora.setSyncWord(0x12)
except Exception:
self.reset_hardware()
def send(self, payload, group=None): def send(self, payload, group=None):
"""Encode la payload en JSON si nécessaire, et injecte automatiquement l'octet de groupe.""" """Encodes payload into JSON and prepends group byte."""
with self.lock: with self.lock:
if self.lora is None:
return
if group is None: if group is None:
group = self.default_group group = self.default_group
# Si c'est un dictionnaire ou une liste, on le convertit en JSON textuel
if isinstance(payload, (dict, list)): if isinstance(payload, (dict, list)):
payload = json.dumps(payload) payload = json.dumps(payload)
if isinstance(payload, str): if isinstance(payload, str):
payload = payload.encode('utf-8') payload = payload.encode('utf-8')
# Insertion automatique de l'octet de groupe au tout début de la trame physique
paquet_physique = bytes([group]) + payload paquet_physique = bytes([group]) + payload
self.lora.send(paquet_physique) try:
self.lora.send(paquet_physique)
except Exception as e:
print(f"[LoRa SPI] Send error: {e}")
def receive_packet(self, timeout_ms=1000): def receive_packet(self, timeout_ms=500):
"""Écoute, nettoie, extrait le groupe, gère le HEX et parse le JSON.""" """Listens on SPI bus with auto-detection for JSON vs. Grouped headers."""
with self.lock: with self.lock:
data, state = self.lora.recv(len=0, timeout_en=True, timeout_ms=timeout_ms) if self.lora is None:
if state == 0 and len(data) > 1: return None
group = data[0]
payload_brute = data[1:].strip(b'\x00 \r\n\t') try:
data, state = self.lora.recv(len=0, timeout_en=True, timeout_ms=timeout_ms)
except Exception as e:
print(f"[LoRa SPI] Recv error caught: {e}")
return None
if state == 0 and data is not None and len(data) > 0:
if data[0] in (0x7B, 0x5B): # Starts with '{' or '['
group = self.default_group
payload_brute = data.strip(b'\x00 \r\n\t')
elif len(data) > 1:
group = data[0]
payload_brute = data[1:].strip(b'\x00 \r\n\t')
else:
return None
try: try:
text = payload_brute.decode('utf-8').strip('\x00 \r\n\t') text = payload_brute.decode('utf-8').strip('\x00 \r\n\t')
@@ -76,13 +281,10 @@ if IS_MICROPYTHON:
return None return None
else: else:
import threading
import serial
import json
# --- PILOTE SÉRIE (Raspberry Pi / Dragino LA66) --- # --- PILOTE SÉRIE (Raspberry Pi / Dragino LA66) ---
class LoraSerialAT: class LoraSerialAT(BaseLoraDevice):
def __init__(self, port): def __init__(self, port):
super().__init__()
self.port = port self.port = port
self.ser = serial.Serial( self.ser = serial.Serial(
port=self.port, port=self.port,
@@ -95,37 +297,60 @@ else:
self.ser.reset_input_buffer() self.ser.reset_input_buffer()
self.ser.reset_output_buffer() self.ser.reset_output_buffer()
self.lock = threading.Lock() self.lock = threading.Lock()
def configure(self, **kwargs):
pass # Initial configuration
self.configure(freq=868.1, sf=7, bw=125)
def send(self, payload): def _send_at_cmd(self, cmd, wait_time=0.15):
"""Encode automatiquement la payload en HEX pour l'envoi via la clé.""" """Helper to send AT command and purge response buffer."""
self.ser.write(f"{cmd}\r\n".encode('utf-8'))
time.sleep(wait_time)
resp = ""
while self.ser.in_waiting > 0:
resp += self.ser.readline().decode('utf-8', errors='ignore')
return resp
def configure(self, freq=868.1, sf=7, bw=125):
"""Configures LA66 frequency, SF, BW, SyncWord, CRC, and continuous RX mode."""
with self.lock: with self.lock:
freq_hz = int(freq * 1000000)
bw_code = 0 if bw == 125 else 1
# Parameters: Freq, SF, BW, CR(0=4/5), Preamble(8), Header(1=Explicit), CRC(1=ON), IQ(0=Standard), NetMode(0=P2P), Power(14), SyncWord(18=0x12), Format(0), Type(1)
at_cfg_cmd = f"AT+CFG={freq_hz},{sf},{bw_code},0,8,1,1,0,0,14,18,0,1"
self._send_at_cmd(at_cfg_cmd, wait_time=0.2)
# Fallback standalone commands
self._send_at_cmd("AT+SYNCWORD=18", wait_time=0.1)
self._send_at_cmd("AT+PRECV=65535", wait_time=0.1)
self.ser.reset_input_buffer()
def send(self, payload, group=None):
"""Encodes payload into HEX AT command and re-enables continuous RX."""
with self.lock:
if group is None:
group = self.default_group
if isinstance(payload, (dict, list)): if isinstance(payload, (dict, list)):
payload = json.dumps(payload) payload = json.dumps(payload)
if isinstance(payload, str): if isinstance(payload, str):
payload = payload.encode('utf-8') payload = payload.encode('utf-8')
hex_payload = payload.hex() paquet_physique = bytes([group]) + payload
hex_payload = paquet_physique.hex()
self.ser.reset_input_buffer() self.ser.reset_input_buffer()
# La clé ajoute d'elle-même l'octet de groupe configuré dans ses registres print(f"[RPi LoRa Serial] Transmitting HEX payload: {hex_payload}")
cmd = f"AT+SEND=1,{hex_payload},1,3\r\n" cmd = f"AT+PSEND={hex_payload}"
# print(f"RPI : Envoi de la commande HEX -> AT+SEND=1,[HEX_DATA],1,3") resp = self._send_at_cmd(cmd, wait_time=0.25) # Wait for RF TX to finish
self.ser.write(cmd.encode('utf-8')) print(f"[RPi LoRa Serial] AT+PSEND response: {resp}")
time.sleep(0.2) # Re-enable continuous receive mode after transmission completes
response = "" self._send_at_cmd("AT+PRECV=65535", wait_time=0.05)
start_wait = time.time()
while (time.time() - start_wait) < 1.5:
if self.ser.in_waiting > 0:
response += self.ser.readline().decode('utf-8', errors='ignore')
time.sleep(0.05)
# print(f"[RPI LA66 TX STATUS] :\n{response.strip()}")
def receive_packet(self, timeout_ms=5000): def receive_packet(self, timeout_ms=500):
"""Reads incoming serial lines from LA66 stick with robust format parsing."""
with self.lock: with self.lock:
start_time = time.time() start_time = time.time()
timeout_s = timeout_ms / 1000.0 timeout_s = timeout_ms / 1000.0
@@ -136,18 +361,38 @@ else:
if line: if line:
payload_bytes = None payload_bytes = None
if "(HEX:)" in line: # Robust parsing for LA66 response variants (+RECV:, +RCV=, +DRX:, HEX:, Data:)
if "+RECV:" in line:
parts = line.split("+RECV:")[1].strip().split(",")
hex_str = parts[2].strip() if len(parts) >= 3 else parts[0].strip()
try: payload_bytes = bytes.fromhex(hex_str)
except ValueError: pass
elif "+RCV=" in line:
parts = line.split("+RCV=")[1].strip().split(",")
if len(parts) >= 4:
try: payload_bytes = bytes.fromhex(parts[3].strip())
except ValueError: pass
elif "+DRX:" in line:
parts = line.split("+DRX:")[1].strip().split(",")
if len(parts) >= 2:
try: payload_bytes = bytes.fromhex(parts[1].strip())
except ValueError: pass
elif "(HEX:)" in line:
hex_part = line.split("(HEX:)")[1].strip().replace(" ", "") hex_part = line.split("(HEX:)")[1].strip().replace(" ", "")
try: try: payload_bytes = bytes.fromhex(hex_part)
payload_bytes = bytes.fromhex(hex_part) except ValueError: pass
except ValueError:
pass
elif "Data:" in line: elif "Data:" in line:
payload_bytes = line.split("Data:")[1].strip().encode('utf-8') payload_bytes = line.split("Data:")[1].strip().encode('utf-8')
if payload_bytes and len(payload_bytes) > 1: if payload_bytes and len(payload_bytes) > 0:
group = payload_bytes[0] if payload_bytes[0] in (0x7B, 0x5B):
payload_clean = payload_bytes[1:].strip(b'\x00 \r\n\t') group = self.default_group
payload_clean = payload_bytes.strip(b'\x00 \r\n\t')
elif len(payload_bytes) > 1:
group = payload_bytes[0]
payload_clean = payload_bytes[1:].strip(b'\x00 \r\n\t')
else:
continue
try: try:
text = payload_clean.decode('utf-8').strip('\x00 \r\n\t') text = payload_clean.decode('utf-8').strip('\x00 \r\n\t')
@@ -180,4 +425,10 @@ def get_lora_device(port_or_pins=None):
return LoraHardwareSPI(**pins) return LoraHardwareSPI(**pins)
else: else:
port = port_or_pins if port_or_pins else "/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0" port = port_or_pins if port_or_pins else "/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0"
return LoraSerialAT(port) return LoraSerialAT(port)
class LoraCommands:
PING = "ping"
COOKING_STATE_UPDATE = "cooking_state_update"
TOGGLE_PAUSE = "toggle_pause"
+106 -34
View File
@@ -11,13 +11,10 @@ try:
except ImportError: except ImportError:
try: try:
from umqtt.simple import MQTTClient as _MQTTClient from umqtt.simple import MQTTClient as _MQTTClient
import _thread
import gc
BACKEND_NAME = "umqtt.simple" BACKEND_NAME = "umqtt.simple"
IS_MICROPYTHON = True IS_MICROPYTHON = True
# except ImportError:
# try:
# from umqtt.robust import MQTTClient as _MQTTClient
# BACKEND_NAME = "umqtt.robust"
# IS_MICROPYTHON = True
except ImportError as exc: except ImportError as exc:
raise ImportError("No MQTT client found. Expected paho.mqtt or umqtt.") from exc raise ImportError("No MQTT client found. Expected paho.mqtt or umqtt.") from exc
@@ -79,6 +76,11 @@ class BrokerClient:
self._client = None self._client = None
self._callback = None self._callback = None
self._messages = [] self._messages = []
self._cadata = None # Cache cert bytes to prevent heap fragmentation
# Thread safety lock for MicroPython socket reads/writes
if IS_MICROPYTHON:
self._lock = _thread.allocate_lock()
def set_callback(self, callback): def set_callback(self, callback):
self._callback = callback self._callback = callback
@@ -104,16 +106,24 @@ class BrokerClient:
return self._client return self._client
if IS_MICROPYTHON: if IS_MICROPYTHON:
gc.collect() # Clean Python heap before importing/allocating SSL
import ssl import ssl
ssl_params = self.ssl_params ssl_params = self.ssl_params
if self.use_tls and ssl_params is None: if self.use_tls and ssl_params is None:
# MicroPython uses context-less structures. # OPTION A: If broker uses 'require_certificate false' and self-signed certs:
# If your CA is self-signed, validation can fail without a valid hostname match. # Do NOT pass cadata when cert_reqs is CERT_NONE to save ~20KB of C-DRAM
ssl_params = { ssl_params = {
"cert_reqs": ssl.CERT_NONE, # Temporarily change to NONE to test if validation is the culprit "cert_reqs": ssl.CERT_NONE,
"cadata": _read_file_bytes(self.cafile) "server_hostname": self.host
} }
# OPTION B: If strict CA validation IS required, load cadata ONLY with CERT_REQUIRED:
# ssl_params = {
# "cert_reqs": ssl.CERT_REQUIRED,
# "cadata": _read_file_bytes(self.cafile),
# "server_hostname": self.host
# }
client = _MQTTClient( client = _MQTTClient(
self.client_id or "smartWave-client", self.client_id or "smartWave-client",
@@ -150,19 +160,35 @@ class BrokerClient:
return self._client return self._client
def connect(self): def connect(self):
client = self.open()
if IS_MICROPYTHON: if IS_MICROPYTHON:
client.connect() gc.collect() # Force C & Python memory cleanup right before TLS handshake
return client
client.connect(self.host, self.port, self.keepalive) if self._client is not None:
return client self.close()
client = self.open()
try:
if IS_MICROPYTHON:
gc.collect() # Sweep memory right before umqtt calls ssl.wrap_socket()
with self._lock:
client.connect()
return client
client.connect(self.host, self.port, self.keepalive)
return client
except Exception as e:
print("MQTT connection failed, closing client and releasing memory.")
print("Exception:", e)
self.close()
raise
def publish(self, topic, payload, qos=2, retain=False): def publish(self, topic, payload, qos=2, retain=False):
client = self.open() client = self.open()
payload_bytes = _ensure_bytes(payload) payload_bytes = _ensure_bytes(payload)
if IS_MICROPYTHON: if IS_MICROPYTHON:
return client.publish(topic, payload_bytes, retain=retain, qos=qos) with self._lock:
return client.publish(topic, payload_bytes, retain=retain, qos=qos)
if isinstance(topic, bytes): if isinstance(topic, bytes):
topic = topic.decode('utf-8') topic = topic.decode('utf-8')
@@ -172,8 +198,9 @@ class BrokerClient:
def subscribe(self, topic, qos=2): def subscribe(self, topic, qos=2):
client = self.open() client = self.open()
if IS_MICROPYTHON: if IS_MICROPYTHON:
client.set_callback(self._on_micropython_message) with self._lock:
return client.subscribe(topic, qos=qos) client.set_callback(self._on_micropython_message)
return client.subscribe(topic, qos=qos)
if isinstance(topic, bytes): if isinstance(topic, bytes):
topic = topic.decode('utf-8') topic = topic.decode('utf-8')
@@ -184,27 +211,43 @@ class BrokerClient:
client = self.open() client = self.open()
if IS_MICROPYTHON: if IS_MICROPYTHON:
import struct import struct
# Ensure the topic is bytes for writing to the socket import time
topic_bytes = topic if isinstance(topic, bytes) else topic.encode('utf-8') topic_bytes = topic if isinstance(topic, bytes) else topic.encode('utf-8')
# 1. Build the MQTT unsubscribe packet header # 1. Increment and lock the PID for THIS specific request
client.pid = (client.pid % 65535) + 1
sent_pid = client.pid # <-- Store local copy
# 2. Construct UNSUBSCRIBE packet
rem_len = 2 + 2 + len(topic_bytes)
pkt = bytearray(b"\xa2\0\0\0") pkt = bytearray(b"\xa2\0\0\0")
client.pid += 1 struct.pack_into("!BH", pkt, 1, rem_len, sent_pid)
# Packet length is: 2 bytes (PID) + 2 bytes (topic length indicator) + topic string length # 3. Write packet to socket
struct.pack_into("!BH", pkt, 1, 2 + 2 + len(topic_bytes), client.pid)
# 2. Write the packet to the socket
client.sock.write(pkt) client.sock.write(pkt)
client._send_str(topic_bytes) client._send_str(topic_bytes)
# 3. Wait for the UNSUBACK confirmation frame (0xB0) from the broker # 4. Wait for UNSUBACK (0xB0)
while True: start = time.time()
while time.time() - start < 3:
op = client.wait_msg() op = client.wait_msg()
if op == 0xB0: if op == 0xB0:
resp = client.sock.read(3) resp = bytearray(3)
assert resp[1] == pkt[2] and resp[2] == pkt[3] read_bytes = 0
while read_bytes < 3:
chunk = client.sock.read(3 - read_bytes)
if chunk:
resp[read_bytes:read_bytes + len(chunk)] = chunk
read_bytes += len(chunk)
else:
time.sleep_ms(10)
# Compare against sent_pid instead of client.pid
resp_pid = (resp[1] << 8) | resp[2]
if resp_pid != sent_pid:
print(f"[MQTT] UNSUBACK PID mismatch (expected {sent_pid}, got {resp_pid})")
return client return client
return client return client
if isinstance(topic, bytes): if isinstance(topic, bytes):
@@ -219,14 +262,16 @@ class BrokerClient:
if self._client is None: if self._client is None:
return None return None
if IS_MICROPYTHON: if IS_MICROPYTHON:
return self._client.check_msg() with self._lock:
return self._client.check_msg()
return self._client.loop(timeout=timeout) return self._client.loop(timeout=timeout)
def wait(self): def wait(self):
if self._client is None: if self._client is None:
return None return None
if IS_MICROPYTHON: if IS_MICROPYTHON:
return self._client.wait_msg() with self._lock:
return self._client.wait_msg()
return self._client.loop_forever() return self._client.loop_forever()
def get_message(self): def get_message(self):
@@ -235,13 +280,40 @@ class BrokerClient:
return self._messages.pop(0) return self._messages.pop(0)
def close(self): def close(self):
"""Safely clean up socket context without causing ESP32 C panics."""
if self._client is None: if self._client is None:
return return
try:
self._client.disconnect() if IS_MICROPYTHON:
except Exception: with self._lock:
try:
if hasattr(self._client, "sock") and self._client.sock:
self._client.sock.close()
except Exception:
pass
finally:
if hasattr(self._client, "sock"):
self._client.sock = None
self._client = None
gc.collect() # Immediately reclaim freed socket & mbedTLS RAM
else:
try:
self._client.disconnect()
except Exception:
pass
finally:
self._client = None
def ping(self):
"""Thread-safe PINGREQ wrapper for MicroPython."""
if self._client is None:
return
if IS_MICROPYTHON:
with self._lock:
return self._client.ping()
else:
# Paho handles keepalives automatically via loop_start/loop
pass pass
self._client = None
def __enter__(self): def __enter__(self):
self.connect() self.connect()
+23
View File
@@ -1,3 +1,6 @@
from time import time
try: try:
import ujson as json import ujson as json
except ImportError: except ImportError:
@@ -20,4 +23,24 @@ def mqtt_hello_ack(id_orchestrator, id_microwave):
return as_json({ return as_json({
"id_microwave": id_microwave, "id_microwave": id_microwave,
"id_orchestrator": id_orchestrator "id_orchestrator": id_orchestrator
})
def mqtt_cooking_init(id_microwave):
return as_json({
"id_microwave": id_microwave
})
def mqtt_sensor_data(id_microwave, dish_temp, ambient_temp):
return as_json({
"id_microwave": id_microwave,
"dish_temp": dish_temp,
"ambient_temp": ambient_temp
})
def mqtt_cooking_config(id_microwave, cook_time, power_level, target_temp):
return as_json({
"id_microwave": id_microwave,
"cook_time": cook_time,
"power_level": power_level,
"target_temp": target_temp
}) })
+37
View File
@@ -0,0 +1,37 @@
import _thread
class SafeQueue:
"""A lightweight, thread-safe FIFO queue for MicroPython."""
def __init__(self, maxsize=20):
self._queue = []
self._lock = _thread.allocate_lock()
self.maxsize = maxsize
def put(self, item) -> bool:
"""Push an item to the end of the queue. Returns False if queue is full."""
with self._lock:
if len(self._queue) < self.maxsize:
self._queue.append(item)
return True
else:
print("[Queue Warning] Buffer full, dropping oldest message.")
self._queue.pop(0) # Drop oldest to make room
self._queue.append(item)
return False
def get(self):
"""Pop and return the oldest item from the queue, or None if empty."""
with self._lock:
if self._queue:
return self._queue.pop(0)
return None
def empty(self) -> bool:
"""Check if the queue has no items."""
with self._lock:
return len(self._queue) == 0
def size(self) -> int:
"""Return current number of queued items."""
with self._lock:
return len(self._queue)
+4
View File
@@ -0,0 +1,4 @@
try:
from shared.sensors.rgb_led import RGBLED
except ImportError:
pass # No need as we are on the RPI
+159
View File
@@ -0,0 +1,159 @@
from machine import Pin, PWM, Timer
import time
class RGBLED:
"""
MicroPython driver for 4-pin RGB LEDs on ESP32 / Heltec boards.
Supports state tracking, color setting, brightness scaling,
state toggling, and non-blocking blinking via machine.Timer.
"""
RED = (255, 0, 0)
GREEN = (0, 255, 0)
BLUE = (0, 0, 255)
YELLOW = (255, 120, 0)
WHITE_YELLOW = (150, 30, 0)
ORANGE = (255, 50, 0)
WHITE = (255, 255, 255)
OFF = (0, 0, 0)
def __init__(self, red_pin, green_pin, blue_pin, common_anode=False, freq=1000, timer_id=1):
"""
:param red_pin: GPIO pin number for Red channel
:param green_pin: GPIO pin number for Green channel
:param blue_pin: GPIO pin number for Blue channel
:param common_anode: Set True if cathode is connected to 3.3V instead of GND
:param freq: PWM frequency in Hz (default 1000Hz)
:param timer_id: Hardware/software timer ID for non-blocking blinks (-1 uses soft timers on ESP32).
"""
self._r_pwm = PWM(Pin(red_pin, Pin.OUT), freq=freq)
self._g_pwm = PWM(Pin(green_pin, Pin.OUT), freq=freq)
self._b_pwm = PWM(Pin(blue_pin, Pin.OUT), freq=freq)
self._common_anode = common_anode
# State tracking variables
self._color = (0, 0, 0) # Current (R, G, B) tuple [0-255]
self._brightness = 1.0 # Brightness factor [0.0 to 1.0]
self._is_on = True # Master power state
# Blink state variables
self._timer = Timer(timer_id)
self._is_blinking = False
self._apply()
def _apply(self):
"""Recalculates and applies PWM duty cycles based on state."""
if not self._is_on:
r, g, b = 0, 0, 0
else:
r = int(self._color[0] * self._brightness)
g = int(self._color[1] * self._brightness)
b = int(self._color[2] * self._brightness)
for pwm, val in ((self._r_pwm, r), (self._g_pwm, g), (self._b_pwm, b)):
# Clamp value between 0 and 255
val = max(0, min(255, val))
# Convert 8-bit (0-255) to MicroPython's 16-bit PWM duty (0-65535)
duty = int((val / 255.0) * 65535)
if self._common_anode:
duty = 65535 - duty
pwm.duty_u16(duty)
# --- Properties and Setters ---
@property
def color(self):
"""Returns the active RGB tuple (R, G, B)."""
return self._color
@color.setter
def color(self, rgb_tuple):
"""Sets the RGB color tuple (e.g., (255, 128, 0))."""
if isinstance(rgb_tuple, (tuple, list)) and len(rgb_tuple) == 3:
self._color = tuple(rgb_tuple)
self._apply()
else:
raise ValueError("Color must be a tuple of 3 integers: (R, G, B)")
@property
def brightness(self):
"""Returns the current brightness level (0.0 to 1.0)."""
return self._brightness
@brightness.setter
def brightness(self, level):
"""Sets brightness level from 0.0 (0%) to 1.0 (100%)."""
self._brightness = max(0.0, min(1.0, float(level)))
self._apply()
@property
def is_on(self):
"""Returns True if the LED is currently powered on."""
return self._is_on
@property
def is_blinking(self):
return self._is_blinking
# --- Basic Control Methods ---
def set_rgb(self, r, g, b):
"""Alternative setter for individual R, G, B integer values."""
self.color = (r, g, b)
def on(self):
"""Turns the LED on using its stored color and brightness."""
self._is_on = True
self._apply()
def off(self):
"""Turns the LED off without resetting the active color state."""
self._is_on = False
self._apply()
def toggle(self):
"""Toggles between ON and OFF states."""
self._is_on = not self._is_on
self._apply()
# --- Non-Blocking Blinking Methods ---
def _timer_callback(self, t):
"""Internal callback executed by machine.Timer."""
self.toggle()
def blink_on(self, interval_ms=500):
"""Starts background blinking at the specified interval in milliseconds."""
if self._is_blinking:
self._timer.deinit()
self._is_blinking = True
self.on() # Ensure initial state is on
self._timer.init(
period=interval_ms,
mode=Timer.PERIODIC,
callback=self._timer_callback
)
def blink_off(self):
"""Stops blinking and returns control to steady state."""
if self._is_blinking:
self._timer.deinit()
self._is_blinking = False
def blink_toggle(self, interval_ms=500):
"""Toggles blinking state (starts if stopped, stops if active)."""
if self._is_blinking:
self.blink_off()
else:
self.blink_on(interval_ms)
def deinit(self):
"""Releases the hardware PWM pins and timer when finished."""
self._r_pwm.deinit()
self._g_pwm.deinit()
self._b_pwm.deinit()
+74 -40
View File
@@ -1,66 +1,100 @@
# shared/uart_comm.py
import _thread import _thread
from machine import UART from machine import UART
import time import time
import ujson
class SafeUART: class SafeUART:
def __init__(self, uart_id, tx_pin, rx_pin, baudrate=115200): def __init__(self, uart_id, tx_pin, rx_pin, baudrate=115200):
# Initialize the hardware UART channel # Setting timeout allows readline() to be non-blocking
self.uart = UART(uart_id, baudrate=baudrate, tx=tx_pin, rx=rx_pin, timeout=10) self.uart = UART(uart_id, baudrate=baudrate, tx=tx_pin, rx=rx_pin, timeout=10, rxbuf=1024)
# Core thread-safety assets
self.lock = _thread.allocate_lock() self.lock = _thread.allocate_lock()
self.rx_queue = [] self.rx_queue = []
self.buffer = b""
# Start the background data worker thread _thread.stack_size(4096)
_thread.stack_size(4096) # Cap the stack size for the UART listener
_thread.start_new_thread(self._listener_worker, ()) _thread.start_new_thread(self._listener_worker, ())
_thread.stack_size(0) _thread.stack_size(0)
print(f"[UART] Thread initialized on UART{uart_id} (TX:{tx_pin}, RX:{rx_pin})")
def _listener_worker(self): def _listener_worker(self):
"""Asynchronous internal loop parsing incoming stream lines into the queue.""" """Simple worker that relies on newline framing instead of manual JSON parsing."""
while True: while True:
try: if self.uart.any():
if self.uart.any(): with self.lock:
with self.lock: line = self.uart.readline()
# Pull all raw bytes waiting in the hardware ring buffer
chunk = self.uart.read(self.uart.any())
if chunk:
self.buffer += chunk
# Process complete lines terminated by a newline character
while b'\n' in self.buffer:
line, self.buffer = self.buffer.split(b'\n', 1)
try:
decoded_line = line.decode('utf-8').strip()
if decoded_line:
self.rx_queue.append(decoded_line)
except Exception:
pass # Discard corrupt data frames safely
except Exception as e:
print("[UART Thread Error]:", e)
time.sleep_ms(20) # Give other background threads breathing room if line:
try:
decoded = line.decode('utf-8').strip()
if decoded: # Ignore empty lines
with self.lock:
self.rx_queue.append(decoded)
except UnicodeError:
pass # Drop corrupted bytes cleanly
time.sleep_ms(10)
def send(self, message): def send(self, message):
"""Safely pushes strings across the serial wire from any thread context."""
if not message.endswith('\n'): if not message.endswith('\n'):
message += '\n' message += '\n'
with self.lock: with self.lock:
self.uart.write(message.encode('utf-8')) self.uart.write(message.encode('utf-8'))
def read(self):
with self.lock:
return self.rx_queue.pop(0) if self.rx_queue else None
def send_as_command(self, command: 'UARTCommand'):
"""Safely sends a structured command over UART."""
json_message = command.to_json()
self.send(json_message)
def any(self): def any(self):
"""Checks if any complete messages are waiting to be read.""" """Checks if any complete messages are waiting to be read."""
with self.lock: with self.lock:
return len(self.rx_queue) > 0 return len(self.rx_queue) > 0
def read_as_command(self) -> 'UARTCommand | None':
"""Attempts to read the oldest unread string and parse it as a UARTCommand. Returns None if empty or invalid."""
raw_message = self.read()
if raw_message is not None:
cmd = UARTCommand.from_json(raw_message)
if cmd is None:
print("[UART] Impossible de traiter le message brut :", raw_message)
return cmd
return None
def read(self):
"""Pulls the oldest unread string from the queue. Returns None if empty.""" class UARTCommand:
with self.lock: """A simple wrapper for commands sent over UART, allowing for structured data."""
if self.rx_queue: def __init__(self, command_type: str, payload):
return self.rx_queue.pop(0) self.command_type = command_type
return None self.payload = payload
def to_json(self):
"""Serializes the command to a JSON string."""
return ujson.dumps({
"command_type": self.command_type,
"payload": self.payload
})
@staticmethod
def from_json(json_string: str) -> 'UARTCommand | None':
"""Deserializes a JSON string into a UARTCommand object."""
try:
# Remplacement préventif si des guillemets simples sont reçus
clean_str = json_string.replace("'", '"') if "'" in json_string else json_string
data = ujson.loads(clean_str)
if not isinstance(data, dict):
return None
return UARTCommand(data.get("command_type"), data.get("payload"))
except Exception as err:
# Affiche l'erreur exacte rencontrée par ujson (ex: syntax error)
print(f"[UARTCommand Parsing Error]: {err} -> Contenu: {json_string}")
return None
class UARTCommandType:
"""Enumeration of known UART command types."""
COOKING_PARAMS = "COOKING_PARAMS"
COOKING_STATE_UPDATE = "COOKING_STATE_UPDATE"
+3 -3
View File
@@ -4,7 +4,7 @@ Edit BROKER_HOST so it points to the broker machine IP address.
Do not use localhost from the ESP32. Do not use localhost from the ESP32.
""" """
from shared.mqtt import BrokerClient import shared
BROKER_HOST = "192.168.50.1" BROKER_HOST = "192.168.50.1"
@@ -17,7 +17,7 @@ def on_message(message):
def main(): def main():
client = BrokerClient( client = shared.get_mqtt_client(
host=BROKER_HOST, host=BROKER_HOST,
client_id="smartwave-esp32-demo", client_id="smartwave-esp32-demo",
use_tls=True, use_tls=True,
@@ -28,7 +28,7 @@ def main():
client.set_callback(on_message) client.set_callback(on_message)
client.connect() client.connect()
client.subscribe(TOPIC, qos=2) client.subscribe(TOPIC, qos=2)
client.publish(TOPIC, b"hello from MicroPython", qos=2, retain=False) client.publish(TOPIC, b"hello from MicroPython", qos=1, retain=False)
for _ in range(30): for _ in range(30):
client.poll() client.poll()