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
# 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
# DEBUG
RUN apt update && apt install -y dnsutils iputils-ping
# Copy the requirements file and install dependencies
# Copy requirements from build context root or relative path
COPY cloud/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 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
# Expose the port the app will run on
EXPOSE 5000
# Use Gunicorn to run the application in production
CMD ["python", "-m", "gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--threads", "4", "--timeout", "300", "app:app"]
# Call gunicorn directly
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
from flask import Flask, request, jsonify
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__)
@@ -22,6 +29,11 @@ device_network_collection = db["device_network"]
CAMERA_IMAGE_DIR = "storage/dishCameraImages"
os.makedirs(CAMERA_IMAGE_DIR, exist_ok=True)
# ---------------------------------------------------------
# Classes
# ---------------------------------------------------------
microwave_cook_planner = MicrowaveCookPlanner()
# ---------------------------------------------------------
# Routes
# ---------------------------------------------------------
@@ -40,40 +52,56 @@ def cooking_params():
if not data:
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
camera_image_b64 = data.get("camera_image")
filepath = None
if camera_image_b64:
# Generate a unique filename using UUID to avoid overwriting
filename = f"dish_{uuid.uuid4().hex}.jpg"
filepath = os.path.join(CAMERA_IMAGE_DIR, filename)
try:
# Decode the base64 string and save it as a binary file
with open(filepath, "wb") as f:
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
except Exception as e:
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:
# Insert the dictionary directly into Mongo (it will retain your exact JSON keys)
cooking_collection.insert_one(data)
# Remove the Mongo-injected '_id' object before returning the response
data.pop("_id", None)
return jsonify({"message": "Cooking parameters saved successfully", "data": data}), 201
except Exception as e:
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"])
def device_network():
@@ -92,7 +120,12 @@ def device_network():
except Exception as e:
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__":
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
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
from machine import Pin
from shared import get_lora, get_uart, deviceTypes, config
from machine import Pin, SoftI2C
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
# --- Configuration Matérielle ---
@@ -18,52 +25,155 @@ except Exception:
# --- Initialisation LoRa ---
lora = get_lora()
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']})")
PING_PAYLOAD = {
"id": DEVICE_ID,
"type": deviceTypes.DEVICE_TYPES["MICROWAVE"]
}
def heartbeat_loop():
last_heartbeat_time = 0
while True:
print(f"\nESP32 : Envoi du Heartbeat...")
# Envoi périodique
ping_payload = {
"id": DEVICE_ID,
"type": deviceTypes.DEVICE_TYPES["MICROWAVE"]
}
lora.send(ping_payload)
now = time.time()
# Le receive_packet est maintenant protégé par le lock dans lora_device
# Si le main thread utilise la radio, ce thread attendra son tour
paquet = lora.receive_packet(timeout_ms=2000)
# 1. Send periodic heartbeat
if now - last_heartbeat_time >= config.LORA_HEARTBEAT_INTERVAL:
last_heartbeat_time = now
print("\nESP32 : Envoi du Heartbeat...")
lora.send(PING_PAYLOAD)
if paquet and not paquet["raw"]:
donnees = paquet["data"]
# 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 ?)")
# 2. Increase listen window to 300ms so radio stays active in RX mode
paquet = lora.receive_reliable(timeout_ms=300)
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_device = get_uart(uart_id=1, tx_pin=46, rx_pin=45)
# 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, ())
# 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 ---
print("[Main] Main execution path active.")
while True:
# 1. Listen for incoming UART serial packets from the WROOM board
while uart_device.any():
command = uart_device.read()
print(f"[Main] Received command from WiFi Board: {command}")
command = uart_device.read_as_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}")
# 2. Send local metrics over the wire to the WiFi board every few seconds
# uart_device.send("Data Pack: LoRa Link RSSI -72dBm")
# Cooking State Update
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}
# 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
paquet = lora.receive_packet(3000)
paquet = lora.receive_reliable(3000)
if paquet:
# paquet est un dict : {"group": 2, "data": {...}, "raw": False}
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)
import esp
from machine import Pin
esp.osdebug(True)
#import webrepl
#webrepl.start()
def do_connect(ssid, pwd):
import network
sta_if = network.WLAN(network.STA_IF)
if not sta_if.isconnected():
print('connecting to network...')
sta_if.active(True)
sta_if.connect(ssid, pwd)
while not sta_if.isconnected():
pass
print('network config:', sta_if.ifconfig())
# def do_connect(ssid, pwd):
# import network
# sta_if = network.WLAN(network.STA_IF)
# sta_if.config(pm=sta_if.PM_NONE)
# if not sta_if.isconnected():
# print('connecting to network...')
# sta_if.active(True)
# sta_if.connect(ssid, pwd)
# while not sta_if.isconnected():
# 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
# 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 select
from machine import Pin
from sensors import temperature_gun
from shared import get_mqtt_client, get_uart, config, payloads
import gc
import sys
import time
import ujson as json
import sys
import uasyncio as asyncio
from machine import Pin, I2C
# Simple thread-safe queue list
msg_queue = []
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)
# 1. Clean memory immediately before performing any operations
gc.collect()
# --- READ DEVICE ID ---
try:
@@ -30,152 +14,341 @@ try:
DEVICE_ID = f.read().strip()
except Exception:
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 ---
from shared import get_mqtt_client, config, payloads
MQTT_CA_FILE = "/certs/ca.crt"
mqtt_client = get_mqtt_client(
host=config.MQTT_BROKER_HOST,
client_id="smartwave-esp32-" + DEVICE_ID,
use_tls=config.USE_TLS,
host="192.168.50.1",
client_id="smartwave-esp32-demo",
use_tls=True,
cafile=MQTT_CA_FILE,
keepalive=config.MQTT_KEEPALIVE,
keepalive=30,
)
global orchestrator_id
orchestrator_id = None
# --- HARDWARE & MODULE DEFERRED IMPORTS ---
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):
print("[MQTT Thread] Received message:", message)
# Try and parse the payload as json, but if it fails, just print the raw payload
payload_data=None
"""Sync callback: Lightweight! Only updates variables or triggers async signals."""
global orchestrator_id, cooking_state, should_unsubscribe_hello
print("[MQTT] Received message on topic:", message.get("topic"))
payload_data = None
try:
payload_data = json.loads(message['payload'])
payload_data = json.loads(message["payload"])
except Exception as e:
print("[MQTT Thread] Error parsing JSON:", 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.")
print("[MQTT] Payload parsing warning:", e)
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():
"""Background MQTT worker handling ALL socket operations safely."""
print("[Thread] Background MQTT worker started.")
# --- DEDICATED ASYNC TASK FOR SENSOR PUBLISHING ---
async def sensor_publisher_task():
"""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:
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()
print("[Thread] Connected! Subscribing to topic...")
print("[MQTT] Connected! Subscribing to topics...")
mqtt_client.subscribe(config.MQTT_TOPIC_COOKING, qos=config.MQTT_QOS)
print("[Thread] Successfully subscribed. Setting up poller...")
poller = select.poll()
poller.register(mqtt_client._client.sock, select.POLLIN)
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)
mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS)
print("[MQTT] Subscribed successfully!")
mqtt_connected = True
return
except Exception as e:
print("[Thread] Connection dropped or error encountered:", e)
print("[MQTT] Connection failed:", 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:
mqtt_client.close()
except Exception:
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:
try:
# 1. Check for incoming messages from the Heltec board
while uart_device.any():
incoming_msg = uart_device.read()
print(f"[Thread] Received from esp-lora over UART: {incoming_msg}")
if mqtt_connected:
try:
mqtt_client.poll()
now = time.time()
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
# uart_device.send("Status Check: WiFi Active")
time.sleep(5) # Fast responsive polling loop for local UART
except Exception as e:
print("[Thread] UART error encountered:", e)
time.sleep(5)
if mqtt_connected:
print("[Hello Task] Sending initial hello to orchestrator...")
try:
if mqtt_client is None:
print("[Hello Task] MQTT client is None. Attempting to reconnect...")
await connect_mqtt_async()
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 ---
# _thread.start_new_thread(mqtt_background_thread, ())
# _thread.start_new_thread(uart_background_thread, ())
await asyncio.sleep(config.MQTT_HELLO_INTERVAL)
# --- MAIN APPLICATION THREAD (Core 0) ---
print("[Main] Main execution path active.")
time.sleep(2) # Give the thread a moment to initial connect
mqtt_hello_sent_timestamp = -config.MQTT_HELLO_INTERVAL
mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS)
async def memory_cleanup_task():
while True:
gc.collect()
await asyncio.sleep(10)
while True:
# MQTT HELLO sent every x seconds until we get a response from the orchestrator
if (orchestrator_id == None and -(mqtt_hello_sent_timestamp - time.time()) > config.MQTT_HELLO_INTERVAL):
print("[Main] Attempting to send initial hello to orchestrator...")
queue_publish(config.MQTT_TOPIC_HELLO, payloads.mqtt_hello(DEVICE_ID))
mqtt_hello_sent_timestamp = time.time()
pass
# Sensors
print(f"[Main] Reading temperature from the gun sensor...")
temp = temperature_gun.read_temperature()
print(f"[Main] Temperature read: {temp}°C")
# 2. Example: Send data to the Heltec board every 5 seconds
# uart_device.send("Status Check: WiFi Active")
time.sleep(1)
# --- MAIN ENTRY POINT ---
async def main():
global sensor_request_event
print("[Main] Starting application...")
# Initialize loop-bound events
sensor_request_event = asyncio.Event()
await connect_mqtt_async()
init_hardware()
# Launch background tasks
asyncio.create_task(mqtt_poll_task())
asyncio.create_task(orchestrator_hello_task())
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 threading
import queue
import time
import traceback
import asyncio
import requests
from orchestrateur.sensors import gps
from shared import get_lora, get_mqtt_client, deviceTypes, config, payloads
from shared.logging import log
from shared.cookingState import CookingStates
from shared.lora_device import LoraCommands
from sensors import ultrasonicRanger, temp_hum, button, camera
# --- Read Unique Device ID ---
try:
with open("device_id.txt", "r") as f:
DEVICE_ID = f.read().strip()
except Exception:
try:
with open("/home/pi/SmartWave/orchestrateur/device_id.txt", "r") as f:
DEVICE_ID = f.read().strip()
except Exception:
DEVICE_ID = "RPI_Orchestrateur_Default"
def get_device_id():
for path in ["device_id.txt", "/home/pi/SmartWave/orchestrateur/device_id.txt"]:
try:
with open(path, "r") as f:
return f.read().strip()
except Exception:
pass
return "RPI_Orchestrateur_Default"
# Thread-safe queue for application messages
data_queue = queue.Queue()
DEVICE_ID = get_device_id()
# --- 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.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(
host="192.168.50.1", # Using explicit gateway IP to dodge Docker loopback blocks
client_id="smartwave-orchestrateur-"+DEVICE_ID,
host="192.168.50.1",
client_id="smartwave-orchestrateur-" + DEVICE_ID,
use_tls=config.USE_TLS,
cafile="/home/pi/SmartWave/orchestrateur/mqtt/certs/ca.crt",
keepalive=config.MQTT_KEEPALIVE,
@@ -55,139 +55,305 @@ mqtt_client = get_mqtt_client(
mqtt_client.connect()
mqtt_client.subscribe(config.MQTT_TOPIC_SENSOR, 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"):
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():
"""Background Thread: Constantly inspects incoming MQTT message cache."""
print("Thread MQTT démarré.")
async def mqtt_listener_task():
"""Polls MQTT cache and pushes to the async queue."""
print("[MQTT] Async listener started.")
while True:
message = mqtt_client.get_message()
if message:
# Try to parse the payload as a python dictionary, but if it fails, just print the raw payload
try:
payload = json.loads(message['payload'])
except Exception as e:
print(f"Error parsing MQTT payload: {e}")
payload = message['payload'] # Fallback to raw payload if parsing fails
except Exception:
payload = message['payload']
print(f"\n[Thread MQTT] Message reçu : {message}")
data_queue.put({"source": "MQTT", "topic": message['topic'] ,"data": payload})
# Sleep for 100ms. Prevents the thread from turning into an infinite 100% CPU hog.
time.sleep(0.2)
# --- SAFE TOPIC DECODING ---
topic = message['topic']
if isinstance(topic, bytes):
topic = topic.decode('utf-8')
await async_event_queue.put({
"source": "MQTT",
"topic": topic,
"data": payload
})
await asyncio.sleep(0.1)
# Button
button_state = False
def button_callback():
"""Button physical interrupt callback."""
global button_state
button_state = not button_state
print(f"\n[Thread Button] Button state changed to: {button_state}")
button.set_callback(button_callback)
if microwave_states.get("2") == MicrowaveState.COOKING:
print("[Button] Toggling pause/resume for microwave '2'.")
lora.send_reliable({"id": DEVICE_ID, "microwave_id": "2", "action": LoraCommands.TOGGLE_PAUSE})
else:
button_state = not button_state
print(f"[Button] Defrost state toggled to: {button_state}")
# Launch background monitoring workers
# threading.Thread(target=lora_listener, daemon=True).start()
# threading.Thread(target=mqtt_listener, daemon=True).start()
# Launch button monitoring thread
button.set_callback(button_callback)
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
def read_sensors():
"""Read all sensors and return a dictionary of their values."""
log("\nLecture des capteurs...")
sensor_data = {}
# --- ASYNC COOKING LOGIC ---
def read_local_sensors(microwave_id, initial_dish_height):
"""Blocking function to read local I2C/SPI sensors. Runs in a thread."""
print(f"[{microwave_id}] Reading local physical sensors...")
sensor_data = {
"microwave_id": microwave_id,
"defrost_mode": button_state,
"ultrasonic_distance": initial_dish_height # Reuse height from trigger
}
# Read Ultrasonic Ranger
distance = ultrasonicRanger.get_dish_height()
if distance is not None:
log(f"\nLecture du capteur Ultrason : {distance}")
sensor_data["ultrasonic_distance"] = distance
# Read Temperature and Humidity
temperature, humidity = temp_hum.get_temperature_and_humidity()
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
# Temp / Hum (handles DHT error safely)
try:
temp, hum = temp_hum.get_temperature_and_humidity_with_retry()
if temp is not None:
sensor_data["temperature"] = temp
sensor_data["humidity"] = hum
except Exception as e:
log(f"[{microwave_id}] DHT read warning: {e}")
# Camera
picture_bytes = None
try:
picture_bytes = camera.get_picture()
log(f"\nLecture du capteur Caméra : {len(picture_bytes)} bytes")
sensor_data["camera_image"] = picture_bytes
sensor_data["camera_image"] = camera.get_picture()
except Exception as e:
log(f"Error reading camera data: {e}")
# Read Button State (last because he can still change state while reading other sensors)
sensor_data["button_state"] = button_state
log(f"[{microwave_id}] Camera read failed: {e}")
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
if hasattr(mqtt_client._client, "loop_stop"):
mqtt_client._client.loop_stop()
mqtt_client.close()
async def handle_new_dish(microwave_id, detected_height):
"""Triggered when a new dish is placed inside."""
microwave_states[microwave_id] = MicrowaveState.ANALYZING
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
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
try:
return grovepi.digitalRead(button)
@@ -26,15 +27,18 @@ def monitor_button():
last_button_state = button_switch_state
while True:
time.sleep(0.04)
current_state = read_button_state()
if current_state is not None:
# Rising edge detection (0 -> 1 transition)
if current_state == 1 and last_button_state == 0:
if button_callback:
button_callback()
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():
threading.Thread(target=monitor_button, daemon=True).start()
@@ -15,8 +15,9 @@
import time,sys
import RPi.GPIO as GPIO
import smbus
from shared import config
debug = 0
debug = config.DEBUG
# use the bus that matches your raspi version
rev = GPIO.RPI_REVISION
if rev == 2 or rev == 3:
+9
View File
@@ -1,5 +1,6 @@
import grovepi
import math
import time
from sensors.lock import grove_lock
# Connect the Grove Temperature & Humidity Sensor Pro to digital port D3
@@ -20,3 +21,11 @@ def get_temperature_and_humidity():
else:
print("Error reading from DHT sensor")
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...")
while True:
paquet = lora.receive_packet(timeout_ms=5000)
paquet = lora.receive_reliable(timeout_ms=5000)
if paquet:
# Plus besoin de décoder du HEX ou de parser du JSON manuellement !
groupe = paquet['group']
+11
View File
@@ -4,6 +4,17 @@
import shared.deviceTypes as deviceTypes
import shared.config as config
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):
from .lora_device import get_lora_device
+1 -1
View File
@@ -1,7 +1,7 @@
DEBUG=True
# LoRa
HEARTBEAT_INTERVAL = 30
LORA_HEARTBEAT_INTERVAL = 30
# MQTT
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 time
import random
IS_MICROPYTHON = sys.implementation.name == 'micropython'
@@ -8,49 +9,253 @@ if IS_MICROPYTHON:
from machine import Pin, SPI
import ubinascii
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) ---
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):
from sx1262 import SX1262
self.lora = SX1262(
spi_bus=spi_bus, clk=clk, mosi=mosi, miso=miso,
cs=cs, irq=irq, rst=rst, gpio=gpio
)
self.default_group = 2 # On définit le groupe par défaut ici
self.lock = _thread.allocate_lock() # Création du verrou
super().__init__()
self._pins = {
"spi_bus": spi_bus, "clk": clk, "mosi": mosi, "miso": miso,
"cs": cs, "irq": irq, "rst": rst, "gpio": gpio
}
self._cfg = {"freq": 868.1, "bw": 125.0, "sf": 7, "cr": 5, "power": 14}
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):
self.lora.begin(
freq=freq, bw=bw, sf=sf, cr=cr, power=power,
useRegulatorLDO=False, crcOn=True, preambleLength=8, implicit=False
)
self.lora.setSyncWord(0x14)
self._cfg = {"freq": freq, "bw": bw, "sf": sf, "cr": cr, "power": power}
if self.lora is None:
self.reset_hardware()
else:
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):
"""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:
if self.lora is None:
return
if group is None:
group = self.default_group
# Si c'est un dictionnaire ou une liste, on le convertit en JSON textuel
if isinstance(payload, (dict, list)):
payload = json.dumps(payload)
if isinstance(payload, str):
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
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):
"""Écoute, nettoie, extrait le groupe, gère le HEX et parse le JSON."""
def receive_packet(self, timeout_ms=500):
"""Listens on SPI bus with auto-detection for JSON vs. Grouped headers."""
with self.lock:
data, state = self.lora.recv(len=0, timeout_en=True, timeout_ms=timeout_ms)
if state == 0 and len(data) > 1:
group = data[0]
payload_brute = data[1:].strip(b'\x00 \r\n\t')
if self.lora is None:
return None
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:
text = payload_brute.decode('utf-8').strip('\x00 \r\n\t')
@@ -76,13 +281,10 @@ if IS_MICROPYTHON:
return None
else:
import threading
import serial
import json
# --- PILOTE SÉRIE (Raspberry Pi / Dragino LA66) ---
class LoraSerialAT:
class LoraSerialAT(BaseLoraDevice):
def __init__(self, port):
super().__init__()
self.port = port
self.ser = serial.Serial(
port=self.port,
@@ -95,37 +297,60 @@ else:
self.ser.reset_input_buffer()
self.ser.reset_output_buffer()
self.lock = threading.Lock()
def configure(self, **kwargs):
pass
# Initial configuration
self.configure(freq=868.1, sf=7, bw=125)
def send(self, payload):
"""Encode automatiquement la payload en HEX pour l'envoi via la clé."""
def _send_at_cmd(self, cmd, wait_time=0.15):
"""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:
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)):
payload = json.dumps(payload)
if isinstance(payload, str):
payload = payload.encode('utf-8')
hex_payload = payload.hex()
paquet_physique = bytes([group]) + payload
hex_payload = paquet_physique.hex()
self.ser.reset_input_buffer()
# La clé ajoute d'elle-même l'octet de groupe configuré dans ses registres
cmd = f"AT+SEND=1,{hex_payload},1,3\r\n"
# print(f"RPI : Envoi de la commande HEX -> AT+SEND=1,[HEX_DATA],1,3")
self.ser.write(cmd.encode('utf-8'))
print(f"[RPi LoRa Serial] Transmitting HEX payload: {hex_payload}")
cmd = f"AT+PSEND={hex_payload}"
resp = self._send_at_cmd(cmd, wait_time=0.25) # Wait for RF TX to finish
print(f"[RPi LoRa Serial] AT+PSEND response: {resp}")
time.sleep(0.2)
response = ""
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()}")
# Re-enable continuous receive mode after transmission completes
self._send_at_cmd("AT+PRECV=65535", wait_time=0.05)
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:
start_time = time.time()
timeout_s = timeout_ms / 1000.0
@@ -136,18 +361,38 @@ else:
if line:
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(" ", "")
try:
payload_bytes = bytes.fromhex(hex_part)
except ValueError:
pass
try: payload_bytes = bytes.fromhex(hex_part)
except ValueError: pass
elif "Data:" in line:
payload_bytes = line.split("Data:")[1].strip().encode('utf-8')
if payload_bytes and len(payload_bytes) > 1:
group = payload_bytes[0]
payload_clean = payload_bytes[1:].strip(b'\x00 \r\n\t')
if payload_bytes and len(payload_bytes) > 0:
if payload_bytes[0] in (0x7B, 0x5B):
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:
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)
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"
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:
try:
from umqtt.simple import MQTTClient as _MQTTClient
import _thread
import gc
BACKEND_NAME = "umqtt.simple"
IS_MICROPYTHON = True
# except ImportError:
# try:
# from umqtt.robust import MQTTClient as _MQTTClient
# BACKEND_NAME = "umqtt.robust"
# IS_MICROPYTHON = True
except ImportError as exc:
raise ImportError("No MQTT client found. Expected paho.mqtt or umqtt.") from exc
@@ -79,6 +76,11 @@ class BrokerClient:
self._client = None
self._callback = None
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):
self._callback = callback
@@ -104,16 +106,24 @@ class BrokerClient:
return self._client
if IS_MICROPYTHON:
gc.collect() # Clean Python heap before importing/allocating SSL
import ssl
ssl_params = self.ssl_params
if self.use_tls and ssl_params is None:
# MicroPython uses context-less structures.
# If your CA is self-signed, validation can fail without a valid hostname match.
# OPTION A: If broker uses 'require_certificate false' and self-signed certs:
# Do NOT pass cadata when cert_reqs is CERT_NONE to save ~20KB of C-DRAM
ssl_params = {
"cert_reqs": ssl.CERT_NONE, # Temporarily change to NONE to test if validation is the culprit
"cadata": _read_file_bytes(self.cafile)
"cert_reqs": ssl.CERT_NONE,
"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(
self.client_id or "smartWave-client",
@@ -150,19 +160,35 @@ class BrokerClient:
return self._client
def connect(self):
client = self.open()
if IS_MICROPYTHON:
client.connect()
return client
gc.collect() # Force C & Python memory cleanup right before TLS handshake
client.connect(self.host, self.port, self.keepalive)
return client
if self._client is not None:
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):
client = self.open()
payload_bytes = _ensure_bytes(payload)
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):
topic = topic.decode('utf-8')
@@ -172,8 +198,9 @@ class BrokerClient:
def subscribe(self, topic, qos=2):
client = self.open()
if IS_MICROPYTHON:
client.set_callback(self._on_micropython_message)
return client.subscribe(topic, qos=qos)
with self._lock:
client.set_callback(self._on_micropython_message)
return client.subscribe(topic, qos=qos)
if isinstance(topic, bytes):
topic = topic.decode('utf-8')
@@ -184,27 +211,43 @@ class BrokerClient:
client = self.open()
if IS_MICROPYTHON:
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')
# 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")
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
struct.pack_into("!BH", pkt, 1, 2 + 2 + len(topic_bytes), client.pid)
# 2. Write the packet to the socket
# 3. Write packet to socket
client.sock.write(pkt)
client._send_str(topic_bytes)
# 3. Wait for the UNSUBACK confirmation frame (0xB0) from the broker
while True:
# 4. Wait for UNSUBACK (0xB0)
start = time.time()
while time.time() - start < 3:
op = client.wait_msg()
if op == 0xB0:
resp = client.sock.read(3)
assert resp[1] == pkt[2] and resp[2] == pkt[3]
resp = bytearray(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
if isinstance(topic, bytes):
@@ -219,14 +262,16 @@ class BrokerClient:
if self._client is None:
return None
if IS_MICROPYTHON:
return self._client.check_msg()
with self._lock:
return self._client.check_msg()
return self._client.loop(timeout=timeout)
def wait(self):
if self._client is None:
return None
if IS_MICROPYTHON:
return self._client.wait_msg()
with self._lock:
return self._client.wait_msg()
return self._client.loop_forever()
def get_message(self):
@@ -235,13 +280,40 @@ class BrokerClient:
return self._messages.pop(0)
def close(self):
"""Safely clean up socket context without causing ESP32 C panics."""
if self._client is None:
return
try:
self._client.disconnect()
except Exception:
if IS_MICROPYTHON:
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
self._client = None
def __enter__(self):
self.connect()
+23
View File
@@ -1,3 +1,6 @@
from time import time
try:
import ujson as json
except ImportError:
@@ -20,4 +23,24 @@ def mqtt_hello_ack(id_orchestrator, id_microwave):
return as_json({
"id_microwave": id_microwave,
"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
from machine import UART
import time
import ujson
class SafeUART:
def __init__(self, uart_id, tx_pin, rx_pin, baudrate=115200):
# Initialize the hardware UART channel
self.uart = UART(uart_id, baudrate=baudrate, tx=tx_pin, rx=rx_pin, timeout=10)
# Core thread-safety assets
# Setting timeout allows readline() to be non-blocking
self.uart = UART(uart_id, baudrate=baudrate, tx=tx_pin, rx=rx_pin, timeout=10, rxbuf=1024)
self.lock = _thread.allocate_lock()
self.rx_queue = []
self.buffer = b""
# Start the background data worker thread
_thread.stack_size(4096) # Cap the stack size for the UART listener
_thread.stack_size(4096)
_thread.start_new_thread(self._listener_worker, ())
_thread.stack_size(0)
print(f"[UART] Thread initialized on UART{uart_id} (TX:{tx_pin}, RX:{rx_pin})")
_thread.stack_size(0)
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:
try:
if self.uart.any():
with self.lock:
# 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)
if self.uart.any():
with self.lock:
line = self.uart.readline()
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):
"""Safely pushes strings across the serial wire from any thread context."""
if not message.endswith('\n'):
message += '\n'
with self.lock:
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):
"""Checks if any complete messages are waiting to be read."""
with self.lock:
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."""
with self.lock:
if self.rx_queue:
return self.rx_queue.pop(0)
return None
class UARTCommand:
"""A simple wrapper for commands sent over UART, allowing for structured data."""
def __init__(self, command_type: str, payload):
self.command_type = command_type
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.
"""
from shared.mqtt import BrokerClient
import shared
BROKER_HOST = "192.168.50.1"
@@ -17,7 +17,7 @@ def on_message(message):
def main():
client = BrokerClient(
client = shared.get_mqtt_client(
host=BROKER_HOST,
client_id="smartwave-esp32-demo",
use_tls=True,
@@ -28,7 +28,7 @@ def main():
client.set_callback(on_message)
client.connect()
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):
client.poll()