Compare commits
18 Commits
a17b9f9725
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c60017e8d | |||
| caf81d4bbb | |||
| 43a1822547 | |||
| 9eac93c409 | |||
| 7299a50198 | |||
| 8ac5db22c1 | |||
| b12296bf0e | |||
| 9e078490dd | |||
| 059bb75555 | |||
| 181009604d | |||
| d6290efb18 | |||
| 2c66a24e9d | |||
| 0f17e9dce6 | |||
| 81de985580 | |||
| 6a42e4a772 | |||
| a0af426c78 | |||
| 5a737c931c | |||
| 181a395b4d |
@@ -0,0 +1,11 @@
|
|||||||
|
.venv
|
||||||
|
venv
|
||||||
|
ENV
|
||||||
|
env
|
||||||
|
.env
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
from APIs.aichat import *
|
||||||
|
from APIs.edamam import *
|
||||||
File diff suppressed because it is too large
Load Diff
+10
-10
@@ -1,24 +1,24 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
# Set the working directory inside the container
|
# Prevent Python from writing .pyc files and buffer stdout/stderr
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV PYTHONPATH=/cloud
|
||||||
|
|
||||||
WORKDIR /cloud
|
WORKDIR /cloud
|
||||||
|
|
||||||
# DEBUG
|
# Copy requirements from build context root or relative path
|
||||||
RUN apt update && apt install -y dnsutils iputils-ping
|
|
||||||
|
|
||||||
# Copy the requirements file and install dependencies
|
|
||||||
COPY cloud/requirements.txt .
|
COPY cloud/requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Copy the rest of the application code
|
# Copy application source code
|
||||||
COPY cloud/ .
|
COPY cloud/ .
|
||||||
COPY shared/ ./shared/
|
COPY shared/ ./shared/
|
||||||
|
|
||||||
# Ensure the photo storage directory exists so the app doesn't crash on startup
|
# Create photo storage directory
|
||||||
RUN mkdir -p storage/dishPhotos
|
RUN mkdir -p storage/dishPhotos
|
||||||
|
|
||||||
# Expose the port the app will run on
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
||||||
# Use Gunicorn to run the application in production
|
# Call gunicorn directly
|
||||||
CMD ["python", "-m", "gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--threads", "4", "--timeout", "300", "app:app"]
|
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--threads", "4", "--timeout", "300", "app:app"]
|
||||||
+49
-16
@@ -3,7 +3,14 @@ import base64
|
|||||||
import uuid
|
import uuid
|
||||||
from flask import Flask, request, jsonify
|
from flask import Flask, request, jsonify
|
||||||
from pymongo import MongoClient
|
from pymongo import MongoClient
|
||||||
from tools.aichat import generate
|
from APIs import generate, EdamamAPI
|
||||||
|
import sys
|
||||||
|
from microwaveCookPlanner import MicrowaveCookPlanner
|
||||||
|
sys.path.insert(0, '..')
|
||||||
|
try:
|
||||||
|
from shared import config
|
||||||
|
except ImportError:
|
||||||
|
from ..shared import config
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
@@ -22,6 +29,11 @@ device_network_collection = db["device_network"]
|
|||||||
CAMERA_IMAGE_DIR = "storage/dishCameraImages"
|
CAMERA_IMAGE_DIR = "storage/dishCameraImages"
|
||||||
os.makedirs(CAMERA_IMAGE_DIR, exist_ok=True)
|
os.makedirs(CAMERA_IMAGE_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# Classes
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
microwave_cook_planner = MicrowaveCookPlanner()
|
||||||
|
|
||||||
# ---------------------------------------------------------
|
# ---------------------------------------------------------
|
||||||
# Routes
|
# Routes
|
||||||
# ---------------------------------------------------------
|
# ---------------------------------------------------------
|
||||||
@@ -40,40 +52,56 @@ def cooking_params():
|
|||||||
if not data:
|
if not data:
|
||||||
return jsonify({"error": "Invalid or missing JSON payload"}), 400
|
return jsonify({"error": "Invalid or missing JSON payload"}), 400
|
||||||
|
|
||||||
|
# Extract user or device parameters (with fallback defaults)
|
||||||
|
height_cm = float(data.get("dish_height", 4.0))
|
||||||
|
initial_temp_c = float(data.get("ir_initial_temp", 20.0)) # e.g., 4.0 for fridge, -18.0 for freezer
|
||||||
|
microwave_wattage = int(data.get("microwave_wattage", 900)) # e.g., 900W
|
||||||
|
defrost_mode = bool(data.get("defrost_mode", False)) # True for defrost, False for cook/reheat
|
||||||
|
print("Parsed parameters - Height (cm):", height_cm, "Initial Temp (C):", initial_temp_c, "Microwave Wattage:", microwave_wattage, "Defrost Mode:", defrost_mode)
|
||||||
|
|
||||||
# 1. Handle the Camera Image
|
# 1. Handle the Camera Image
|
||||||
camera_image_b64 = data.get("camera_image")
|
camera_image_b64 = data.get("camera_image")
|
||||||
|
filepath = None
|
||||||
|
|
||||||
if camera_image_b64:
|
if camera_image_b64:
|
||||||
# Generate a unique filename using UUID to avoid overwriting
|
|
||||||
filename = f"dish_{uuid.uuid4().hex}.jpg"
|
filename = f"dish_{uuid.uuid4().hex}.jpg"
|
||||||
filepath = os.path.join(CAMERA_IMAGE_DIR, filename)
|
filepath = os.path.join(CAMERA_IMAGE_DIR, filename)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Decode the base64 string and save it as a binary file
|
|
||||||
with open(filepath, "wb") as f:
|
with open(filepath, "wb") as f:
|
||||||
f.write(base64.b64decode(camera_image_b64))
|
f.write(base64.b64decode(camera_image_b64))
|
||||||
|
|
||||||
# Replace the giant base64 string in the dictionary with the local file path
|
|
||||||
# so we don't bloat the MongoDB document
|
|
||||||
data["camera_image"] = filepath
|
data["camera_image"] = filepath
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": f"Failed to save camera image: {str(e)}"}), 500
|
return jsonify({"error": f"Failed to save camera image: {str(e)}"}), 500
|
||||||
|
else:
|
||||||
|
return jsonify({"error": "Missing required field 'camera_image'"}), 400
|
||||||
|
|
||||||
# 2. Save to MongoDB
|
# 2. Run the Cook Planning Engine
|
||||||
|
try:
|
||||||
|
cook_plan = microwave_cook_planner.generate_plan(
|
||||||
|
image_path=filepath,
|
||||||
|
height_cm=height_cm,
|
||||||
|
initial_temp_c=initial_temp_c,
|
||||||
|
microwave_wattage=microwave_wattage,
|
||||||
|
defrost_mode=defrost_mode
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": f"Failed to compute cooking plan: {str(e)}"}), 500
|
||||||
|
|
||||||
|
# 3. Attach cooking parameters to database record
|
||||||
|
data["analysis_results"] = cook_plan
|
||||||
|
|
||||||
|
# 4. Save to MongoDB
|
||||||
try:
|
try:
|
||||||
# Insert the dictionary directly into Mongo (it will retain your exact JSON keys)
|
|
||||||
cooking_collection.insert_one(data)
|
cooking_collection.insert_one(data)
|
||||||
|
|
||||||
# Remove the Mongo-injected '_id' object before returning the response
|
|
||||||
data.pop("_id", None)
|
data.pop("_id", None)
|
||||||
return jsonify({"message": "Cooking parameters saved successfully", "data": data}), 201
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": f"Database error: {str(e)}"}), 500
|
return jsonify({"error": f"Database error: {str(e)}"}), 500
|
||||||
|
|
||||||
# 3. Returns with the cooking parameters
|
# 5. Return complete output
|
||||||
|
return jsonify(cook_plan), 201
|
||||||
|
|
||||||
|
|
||||||
@app.route("/device-network", methods=["POST"])
|
@app.route("/device-network", methods=["POST"])
|
||||||
def device_network():
|
def device_network():
|
||||||
@@ -92,7 +120,12 @@ def device_network():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": f"Database error: {str(e)}"}), 500
|
return jsonify({"error": f"Database error: {str(e)}"}), 500
|
||||||
|
|
||||||
|
@app.route("/debug", methods=["GET"])
|
||||||
|
def debug():
|
||||||
|
image_path = "microwaveDish.jpg"
|
||||||
|
edamam = EdamamAPI()
|
||||||
|
return edamam.analyze_dish_image(image_path)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(debug=True)
|
app.run(debug=config.DEBUG)
|
||||||
@@ -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 |
@@ -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,
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
Flask==3.0.2
|
Flask==3.0.2
|
||||||
pymongo==4.6.1
|
pymongo==4.6.1
|
||||||
gunicorn==21.2.0
|
gunicorn==21.2.0
|
||||||
|
opencv-python-headless
|
||||||
|
requests==2.32.3
|
||||||
+138
-28
@@ -1,6 +1,13 @@
|
|||||||
import _thread
|
import _thread
|
||||||
from machine import Pin
|
from machine import Pin, SoftI2C
|
||||||
from shared import get_lora, get_uart, deviceTypes, config
|
from shared.safeQueue import SafeQueue
|
||||||
|
from shared import get_lora, get_uart, deviceTypes, config, cookingState
|
||||||
|
from shared.uart_comm import UARTCommand, UARTCommandType
|
||||||
|
from shared.sensors import RGBLED
|
||||||
|
from shared.logging import log
|
||||||
|
from shared.lora_device import LoraCommands
|
||||||
|
import framebuf
|
||||||
|
import ssd1306
|
||||||
import time
|
import time
|
||||||
|
|
||||||
# --- Configuration Matérielle ---
|
# --- Configuration Matérielle ---
|
||||||
@@ -18,52 +25,155 @@ except Exception:
|
|||||||
# --- Initialisation LoRa ---
|
# --- Initialisation LoRa ---
|
||||||
lora = get_lora()
|
lora = get_lora()
|
||||||
lora.configure(freq=868.1, sf=7)
|
lora.configure(freq=868.1, sf=7)
|
||||||
|
data_queue = SafeQueue()
|
||||||
|
|
||||||
|
# --- Création des lEDs RGB ---
|
||||||
|
magnetron_led = RGBLED(red_pin=48, green_pin=47, blue_pin=33)
|
||||||
|
magnetron_led.color = RGBLED.WHITE_YELLOW
|
||||||
|
magnetron_led.off()
|
||||||
|
|
||||||
|
# --- Création de l'écran OLED ---
|
||||||
|
scl_pin = Pin(18, Pin.OUT, pull=Pin.PULL_UP)
|
||||||
|
sda_pin = Pin(17, Pin.OUT, pull=Pin.PULL_UP)
|
||||||
|
display_i2c = SoftI2C(scl=scl_pin, sda=sda_pin, freq=100000)
|
||||||
|
display = ssd1306.SSD1306_I2C(128, 64, display_i2c, addr=0x3C)
|
||||||
|
display.text("Booting...", 1, 2, 1)
|
||||||
|
display.show()
|
||||||
|
|
||||||
print(f"ESP32 initialisé avec l'ID : '{DEVICE_ID}' (Type : {deviceTypes.DEVICE_TYPES['MICROWAVE']})")
|
print(f"ESP32 initialisé avec l'ID : '{DEVICE_ID}' (Type : {deviceTypes.DEVICE_TYPES['MICROWAVE']})")
|
||||||
|
|
||||||
|
PING_PAYLOAD = {
|
||||||
|
"id": DEVICE_ID,
|
||||||
|
"type": deviceTypes.DEVICE_TYPES["MICROWAVE"]
|
||||||
|
}
|
||||||
|
|
||||||
def heartbeat_loop():
|
def heartbeat_loop():
|
||||||
|
last_heartbeat_time = 0
|
||||||
while True:
|
while True:
|
||||||
print(f"\nESP32 : Envoi du Heartbeat...")
|
now = time.time()
|
||||||
# Envoi périodique
|
|
||||||
ping_payload = {
|
|
||||||
"id": DEVICE_ID,
|
|
||||||
"type": deviceTypes.DEVICE_TYPES["MICROWAVE"]
|
|
||||||
}
|
|
||||||
lora.send(ping_payload)
|
|
||||||
|
|
||||||
# Le receive_packet est maintenant protégé par le lock dans lora_device
|
# 1. Send periodic heartbeat
|
||||||
# Si le main thread utilise la radio, ce thread attendra son tour
|
if now - last_heartbeat_time >= config.LORA_HEARTBEAT_INTERVAL:
|
||||||
paquet = lora.receive_packet(timeout_ms=2000)
|
last_heartbeat_time = now
|
||||||
|
print("\nESP32 : Envoi du Heartbeat...")
|
||||||
|
lora.send(PING_PAYLOAD)
|
||||||
|
|
||||||
if paquet and not paquet["raw"]:
|
# 2. Increase listen window to 300ms so radio stays active in RX mode
|
||||||
donnees = paquet["data"]
|
paquet = lora.receive_reliable(timeout_ms=300)
|
||||||
# Vérification si le paquet reçu est bien la réponse attendue de l'orchestrateur
|
|
||||||
if donnees.get("type") == deviceTypes.DEVICE_TYPES["ORCHESTRATOR"]:
|
|
||||||
print(f"ESP32 : Réponse reçue de l'orchestrateur '{donnees.get('id')}' ! [Statut: ALIVE]")
|
|
||||||
else:
|
|
||||||
print(f"ESP32 : Paquet reçu d'un type inattendu : {donnees.get('type')}")
|
|
||||||
else:
|
|
||||||
print("ESP32 : Pas de réponse de l'orchestrateur (Le RPI est-il éteint ?)")
|
|
||||||
|
|
||||||
time.sleep(config.HEARTBEAT_INTERVAL)
|
if paquet is not None:
|
||||||
|
log(f"[LoRa Thread] New Packet Received: {paquet}")
|
||||||
|
data_queue.put(paquet)
|
||||||
|
|
||||||
|
time.sleep_ms(10)
|
||||||
|
|
||||||
# UART
|
# UART
|
||||||
uart_device = get_uart(uart_id=1, tx_pin=46, rx_pin=45)
|
uart_device = get_uart(uart_id=1, tx_pin=46, rx_pin=45)
|
||||||
|
|
||||||
# Lancer la boucle de heartbeat dans un thread séparé
|
# Lancer la boucle de heartbeat dans un thread séparé
|
||||||
|
try:
|
||||||
|
_thread.stack_size(16 * 1024)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
_thread.start_new_thread(heartbeat_loop, ())
|
_thread.start_new_thread(heartbeat_loop, ())
|
||||||
|
|
||||||
|
# Cooking parameters
|
||||||
|
cooking_state = None
|
||||||
|
def cooking_state_temperature_provider():
|
||||||
|
return 22.0, 29.0 # TODO Remplacer par la lecture réelle de la température du plat et de l'air ambiant
|
||||||
|
|
||||||
|
def cooking_state_on_state_change(state):
|
||||||
|
print(f"[Main] Cooking state changed to: {state.state}")
|
||||||
|
|
||||||
|
# Send to the Wifi board the current state
|
||||||
|
uart_device.send_as_command(UARTCommand(UARTCommandType.COOKING_STATE_UPDATE, {"state": state.state}))
|
||||||
|
# Send to the orchestrator the current state
|
||||||
|
lora.send_reliable({"id": DEVICE_ID, "new_cooking_state": state.state})
|
||||||
|
|
||||||
|
display.text(cookingState.CookingStates.get_state_name(state.state), 1, 2, 1)
|
||||||
|
display.show()
|
||||||
|
|
||||||
|
if state.paused or state.state == cookingState.CookingStates.DONE or state.state == cookingState.CookingStates.IDLE:
|
||||||
|
magnetron_led.off()
|
||||||
|
else:
|
||||||
|
magnetron_led.on()
|
||||||
|
|
||||||
|
|
||||||
|
if state.state == cookingState.CookingStates.COOKING:
|
||||||
|
pass
|
||||||
|
if state.state == cookingState.CookingStates.STIRRING_REQUIRED:
|
||||||
|
pass
|
||||||
|
if state.state == cookingState.CookingStates.DONE:
|
||||||
|
pass
|
||||||
|
if state.state == cookingState.CookingStates.ALERT:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def cooking_state_on_refresh(state):
|
||||||
|
# TODO Show screen information
|
||||||
|
pass
|
||||||
|
|
||||||
|
def cooking_state_on_pause(state):
|
||||||
|
# If the cooking is unpaused and was in STIRRING_REQUIRED or ALERT state, we set the state back to COOKING.
|
||||||
|
if not state.paused and (state.state == cookingState.CookingStates.STIRRING_REQUIRED or state.state == cookingState.CookingStates.ALERT):
|
||||||
|
state.set_state(cookingState.CookingStates.COOKING)
|
||||||
|
|
||||||
|
# TODO send_reliable lora message to orchestrator about pause/resume state
|
||||||
|
|
||||||
|
|
||||||
# --- MAIN APPLICATION THREAD ---
|
# --- MAIN APPLICATION THREAD ---
|
||||||
print("[Main] Main execution path active.")
|
print("[Main] Main execution path active.")
|
||||||
while True:
|
while True:
|
||||||
# 1. Listen for incoming UART serial packets from the WROOM board
|
# 1. Listen for incoming UART serial packets from the WROOM board
|
||||||
while uart_device.any():
|
while uart_device.any():
|
||||||
command = uart_device.read()
|
command = uart_device.read_as_command()
|
||||||
print(f"[Main] Received command from WiFi Board: {command}")
|
if command:
|
||||||
|
print(f"[Main] Received command from WiFi Board: {command.command_type}")
|
||||||
|
if command.command_type == UARTCommandType.COOKING_PARAMS:
|
||||||
|
# Handle cooking parameters command
|
||||||
|
params = command.payload
|
||||||
|
print(f"[Main] Cooking parameters received: {params}")
|
||||||
|
cooking_state = cookingState.CookingState(
|
||||||
|
cook_time=params["cook_time"],
|
||||||
|
power_level=params["power_level"],
|
||||||
|
target_temp=params["target_temp"]
|
||||||
|
)
|
||||||
|
cooking_state.set_temperature_provider(cooking_state_temperature_provider)
|
||||||
|
cooking_state.set_state_change_callback(cooking_state_on_state_change)
|
||||||
|
cooking_state.set_refresh_callback(cooking_state_on_refresh)
|
||||||
|
cooking_state.set_pause_callback(cooking_state_on_pause)
|
||||||
|
time.sleep_ms(20) # Before sending back right away
|
||||||
|
cooking_state_on_state_change(cooking_state)
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"[Main] Unknown command type received: {command.command_type}")
|
||||||
|
# 2. Listen for incoming LoRa packets from the orchestrator
|
||||||
|
while not data_queue.empty():
|
||||||
|
paquet = data_queue.get()
|
||||||
|
if paquet and not paquet["raw"]:
|
||||||
|
data = paquet["data"]
|
||||||
|
# Commands
|
||||||
|
if "action" in data:
|
||||||
|
if data["action"] == LoraCommands.TOGGLE_PAUSE:
|
||||||
|
if cooking_state != None:
|
||||||
|
if (cooking_state.state == cookingState.CookingStates.DONE):
|
||||||
|
print("[Main] Cooking is done. We reset the microwave for the next cooking session.")
|
||||||
|
cooking_state.set_state(cookingState.CookingStates.IDLE)
|
||||||
|
time.sleep_ms(20) # Before sending back right away
|
||||||
|
cooking_state = None
|
||||||
|
else:
|
||||||
|
cooking_state.toggle_pause()
|
||||||
|
if cooking_state.paused:
|
||||||
|
print("[Main] Cooking paused via orchestrator command.")
|
||||||
|
else:
|
||||||
|
print("[Main] Cooking resumed via orchestrator command.")
|
||||||
|
else:
|
||||||
|
log("[Main] No active cooking state to toggle pause/resume.")
|
||||||
|
|
||||||
# uart_device.send(f"Hello from esp-32 lora ID {DEVICE_ID}")
|
# uart_device.send(f"Hello from esp-32 lora ID {DEVICE_ID}")
|
||||||
|
|
||||||
# 2. Send local metrics over the wire to the WiFi board every few seconds
|
# Cooking State Update
|
||||||
# uart_device.send("Data Pack: LoRa Link RSSI -72dBm")
|
if cooking_state != None:
|
||||||
|
cooking_state.update_tick()
|
||||||
|
print(f"[Main] Cooking state : State : {cooking_state.state}, Temperature: {cooking_state.current_dish_temp}, Paused: {cooking_state.paused}, Remaining Time: {cooking_state.get_remaining_time():.2f}s, Estimated Remaining Time: {cooking_state.get_remaining_time_estimation():.2f}s")
|
||||||
|
|
||||||
time.sleep_ms(200)
|
time.sleep_ms(500)
|
||||||
@@ -14,10 +14,10 @@ while True:
|
|||||||
mesures = {"id": "ESP32_Salon", "temp": 22.4, "hum": 55.2}
|
mesures = {"id": "ESP32_Salon", "temp": 22.4, "hum": 55.2}
|
||||||
|
|
||||||
# Envoi direct (le pilote s'occupe de mettre le groupe \x02)
|
# Envoi direct (le pilote s'occupe de mettre le groupe \x02)
|
||||||
lora.send(b'\x02' + lora.send_json_bytes_helper if False else bytes([2]) + lora.send_helper if False else b'\x02' + __import__('ujson').dumps(mesures).encode('utf-8'))
|
lora.send_reliable(b'\x02' + lora.send_json_bytes_helper if False else bytes([2]) + lora.send_helper if False else b'\x02' + __import__('ujson').dumps(mesures).encode('utf-8'))
|
||||||
|
|
||||||
# Réception propre
|
# Réception propre
|
||||||
paquet = lora.receive_packet(3000)
|
paquet = lora.receive_reliable(3000)
|
||||||
if paquet:
|
if paquet:
|
||||||
# paquet est un dict : {"group": 2, "data": {...}, "raw": False}
|
# paquet est un dict : {"group": 2, "data": {...}, "raw": False}
|
||||||
print(f"ESP32 : Message reçu du groupe {paquet['group']}")
|
print(f"ESP32 : Message reçu du groupe {paquet['group']}")
|
||||||
|
|||||||
@@ -1,19 +1,58 @@
|
|||||||
# This file is executed on every boot (including wake-boot from deepsleep)
|
# This file is executed on every boot (including wake-boot from deepsleep)
|
||||||
import esp
|
import esp
|
||||||
|
from machine import Pin
|
||||||
esp.osdebug(True)
|
esp.osdebug(True)
|
||||||
#import webrepl
|
#import webrepl
|
||||||
#webrepl.start()
|
#webrepl.start()
|
||||||
|
|
||||||
def do_connect(ssid, pwd):
|
# def do_connect(ssid, pwd):
|
||||||
import network
|
# import network
|
||||||
sta_if = network.WLAN(network.STA_IF)
|
# sta_if = network.WLAN(network.STA_IF)
|
||||||
if not sta_if.isconnected():
|
# sta_if.config(pm=sta_if.PM_NONE)
|
||||||
print('connecting to network...')
|
# if not sta_if.isconnected():
|
||||||
sta_if.active(True)
|
# print('connecting to network...')
|
||||||
sta_if.connect(ssid, pwd)
|
# sta_if.active(True)
|
||||||
while not sta_if.isconnected():
|
# sta_if.connect(ssid, pwd)
|
||||||
pass
|
# while not sta_if.isconnected():
|
||||||
print('network config:', sta_if.ifconfig())
|
# pass
|
||||||
|
# print('network config:', sta_if.ifconfig())
|
||||||
|
|
||||||
|
import network
|
||||||
|
import time
|
||||||
|
|
||||||
|
def do_connect(ssid, password):
|
||||||
|
wlan = network.WLAN(network.STA_IF)
|
||||||
|
|
||||||
|
# 1. ALWAYS activate the interface FIRST
|
||||||
|
if not wlan.active():
|
||||||
|
wlan.active(True)
|
||||||
|
|
||||||
|
# 2. Configure Wi-Fi options AFTER activation
|
||||||
|
try:
|
||||||
|
# Disable Wi-Fi modem sleep (0 = PM_NONE)
|
||||||
|
wlan.config(pm=0)
|
||||||
|
except Exception as e:
|
||||||
|
print("[Wi-Fi] Warning: Failed to set power management:", e)
|
||||||
|
|
||||||
|
# 3. Connect to the access point
|
||||||
|
if not wlan.isconnected():
|
||||||
|
print(f"[Wi-Fi] Connecting to {ssid}...")
|
||||||
|
wlan.connect(ssid, password)
|
||||||
|
|
||||||
|
timeout = 15
|
||||||
|
start_time = time.time()
|
||||||
|
while not wlan.isconnected():
|
||||||
|
if time.time() - start_time > timeout:
|
||||||
|
print("[Wi-Fi] Connection timed out!")
|
||||||
|
return False
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
print("[Wi-Fi] Connected! Network config:", wlan.ifconfig())
|
||||||
|
return True
|
||||||
|
|
||||||
# Attempt to connect to WiFi network
|
# Attempt to connect to WiFi network
|
||||||
# do_connect("Smartwave-1", 'Smartwave-prot-1')
|
do_connect("Smartwave-1", 'Smartwave-prot-1')
|
||||||
|
|
||||||
|
# Set PIN 27 as GND for the temperature sensor (MLX90614)
|
||||||
|
sensor_gnd = Pin(27, Pin.OUT)
|
||||||
|
sensor_gnd.value(0)
|
||||||
+314
-141
@@ -1,28 +1,12 @@
|
|||||||
import _thread
|
import gc
|
||||||
import select
|
import sys
|
||||||
from machine import Pin
|
|
||||||
from sensors import temperature_gun
|
|
||||||
from shared import get_mqtt_client, get_uart, config, payloads
|
|
||||||
import time
|
import time
|
||||||
import ujson as json
|
import ujson as json
|
||||||
import sys
|
import uasyncio as asyncio
|
||||||
|
from machine import Pin, I2C
|
||||||
|
|
||||||
# Simple thread-safe queue list
|
# 1. Clean memory immediately before performing any operations
|
||||||
msg_queue = []
|
gc.collect()
|
||||||
queue_lock = _thread.allocate_lock()
|
|
||||||
|
|
||||||
def queue_publish(topic, payload):
|
|
||||||
"""Safely queues a message from the main thread."""
|
|
||||||
with queue_lock:
|
|
||||||
msg_queue.append((topic, payload))
|
|
||||||
|
|
||||||
# --- INITIALIZE CAMERA ---
|
|
||||||
try:
|
|
||||||
# Pass your confirmed working SCL and SDA pins here
|
|
||||||
temperature_gun.init_camera(scl_pin=21, sda_pin=22, freq=100000)
|
|
||||||
except Exception as e:
|
|
||||||
print("[Main] Critical: Camera setup failed!")
|
|
||||||
sys.print_exception(e)
|
|
||||||
|
|
||||||
# --- READ DEVICE ID ---
|
# --- READ DEVICE ID ---
|
||||||
try:
|
try:
|
||||||
@@ -30,152 +14,341 @@ try:
|
|||||||
DEVICE_ID = f.read().strip()
|
DEVICE_ID = f.read().strip()
|
||||||
except Exception:
|
except Exception:
|
||||||
DEVICE_ID = "ESP32_Inconnu"
|
DEVICE_ID = "ESP32_Inconnu"
|
||||||
|
|
||||||
|
# --- GLOBAL APP STATE ---
|
||||||
|
orchestrator_id = None
|
||||||
|
cooking_state = None
|
||||||
|
mqtt_connected = False
|
||||||
|
should_unsubscribe_hello = False
|
||||||
|
|
||||||
|
# --- ASYNC SIGNALS & QUEUES ---
|
||||||
|
# Event to signal when orchestrator requests sensor data (prevents MQTT lock deadlock)
|
||||||
|
sensor_request_event = None
|
||||||
|
|
||||||
# --- MQTT SETUP ---
|
# --- MQTT SETUP ---
|
||||||
|
from shared import get_mqtt_client, config, payloads
|
||||||
|
|
||||||
MQTT_CA_FILE = "/certs/ca.crt"
|
MQTT_CA_FILE = "/certs/ca.crt"
|
||||||
|
|
||||||
mqtt_client = get_mqtt_client(
|
mqtt_client = get_mqtt_client(
|
||||||
host=config.MQTT_BROKER_HOST,
|
host="192.168.50.1",
|
||||||
client_id="smartwave-esp32-" + DEVICE_ID,
|
client_id="smartwave-esp32-demo",
|
||||||
use_tls=config.USE_TLS,
|
use_tls=True,
|
||||||
cafile=MQTT_CA_FILE,
|
cafile=MQTT_CA_FILE,
|
||||||
keepalive=config.MQTT_KEEPALIVE,
|
keepalive=30,
|
||||||
)
|
)
|
||||||
|
|
||||||
global orchestrator_id
|
# --- HARDWARE & MODULE DEFERRED IMPORTS ---
|
||||||
orchestrator_id = None
|
status_led = None
|
||||||
|
uart_device = None
|
||||||
|
mlx_temperature_sensor = None
|
||||||
|
cookingState = None
|
||||||
|
log = None
|
||||||
|
UARTCommand = None
|
||||||
|
UARTCommandType = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_hardware():
|
||||||
|
"""Initializes hardware peripherals AFTER MQTT TLS has reserved its RAM."""
|
||||||
|
global status_led, uart_device, mlx_temperature_sensor
|
||||||
|
global cookingState, log, UARTCommand, UARTCommandType
|
||||||
|
|
||||||
|
print("[Main] Initializing hardware peripherals...")
|
||||||
|
|
||||||
|
from shared import get_uart, cookingState as cs, logging
|
||||||
|
from shared.uart_comm import UARTCommand as UC, UARTCommandType as UCT
|
||||||
|
from shared.sensors import RGBLED
|
||||||
|
from sensors import temperature_sensor
|
||||||
|
|
||||||
|
cookingState = cs
|
||||||
|
log = logging.log
|
||||||
|
UARTCommand = UC
|
||||||
|
UARTCommandType = UCT
|
||||||
|
|
||||||
|
status_led = RGBLED(red_pin=21, green_pin=19, blue_pin=18)
|
||||||
|
uart_device = get_uart(uart_id=2, tx_pin=17, rx_pin=16)
|
||||||
|
|
||||||
|
temperature_sensor_i2c = I2C(
|
||||||
|
0,
|
||||||
|
scl=Pin(25, Pin.IN, Pin.PULL_UP),
|
||||||
|
sda=Pin(26, Pin.IN, Pin.PULL_UP),
|
||||||
|
freq=100000,
|
||||||
|
)
|
||||||
|
devices = temperature_sensor_i2c.scan()
|
||||||
|
if 0x5A in devices:
|
||||||
|
print("[Main] MLX90614 found at address 0x5A!")
|
||||||
|
else:
|
||||||
|
print("[Main] MLX90614 not found on I2C bus.")
|
||||||
|
mlx_temperature_sensor = temperature_sensor.MLX90614(temperature_sensor_i2c)
|
||||||
|
|
||||||
|
|
||||||
|
def on_received_cooking_state_update(state, is_error=False, is_terminated=False):
|
||||||
|
"""Callback executed when state changes are received from the LoRa board over UART."""
|
||||||
|
if cooking_state:
|
||||||
|
if is_error:
|
||||||
|
cooking_state.set_state(cookingState.CookingStates.ERROR)
|
||||||
|
elif is_terminated:
|
||||||
|
cooking_state.set_state(cookingState.CookingStates.ABORTED)
|
||||||
|
else:
|
||||||
|
cooking_state.set_state(state)
|
||||||
|
|
||||||
|
|
||||||
|
def on_cooking_state_change(state):
|
||||||
|
"""Callback executed whenever local cooking state transitions."""
|
||||||
|
BLINK_INTERVAL_MS = 500
|
||||||
|
|
||||||
|
if status_led and cookingState:
|
||||||
|
if state == cookingState.CookingStates.IDLE:
|
||||||
|
status_led.color = status_led.OFF
|
||||||
|
status_led.blink_off()
|
||||||
|
elif state == cookingState.CookingStates.COOKING:
|
||||||
|
status_led.color = status_led.YELLOW
|
||||||
|
status_led.blink_off()
|
||||||
|
elif state == cookingState.CookingStates.STIRRING_REQUIRED:
|
||||||
|
status_led.color = status_led.ORANGE
|
||||||
|
status_led.blink_on(BLINK_INTERVAL_MS)
|
||||||
|
elif state == cookingState.CookingStates.ALERT:
|
||||||
|
status_led.color = status_led.RED
|
||||||
|
status_led.blink_on(BLINK_INTERVAL_MS)
|
||||||
|
elif state == cookingState.CookingStates.DONE:
|
||||||
|
status_led.color = status_led.GREEN
|
||||||
|
status_led.blink_off()
|
||||||
|
|
||||||
|
|
||||||
def on_mqtt_message(message):
|
def on_mqtt_message(message):
|
||||||
print("[MQTT Thread] Received message:", message)
|
"""Sync callback: Lightweight! Only updates variables or triggers async signals."""
|
||||||
|
global orchestrator_id, cooking_state, should_unsubscribe_hello
|
||||||
# Try and parse the payload as json, but if it fails, just print the raw payload
|
print("[MQTT] Received message on topic:", message.get("topic"))
|
||||||
payload_data=None
|
|
||||||
|
payload_data = None
|
||||||
try:
|
try:
|
||||||
payload_data = json.loads(message['payload'])
|
payload_data = json.loads(message["payload"])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("[MQTT Thread] Error parsing JSON:", e)
|
print("[MQTT] Payload parsing warning:", e)
|
||||||
sys.print_exception(e)
|
|
||||||
pass # Maybe it's not JSON
|
|
||||||
|
|
||||||
if message['topic'] == config.MQTT_TOPIC_HELLO and payload_data and "id_orchestrator" in payload_data and payload_data["id_microwave"] == DEVICE_ID:
|
|
||||||
print("[MQTT Thread] Hello response received from orchestrator:", payload_data["id_orchestrator"])
|
|
||||||
global orchestrator_id
|
|
||||||
orchestrator_id = payload_data["id_orchestrator"]
|
|
||||||
# Unsubscribe from the hello topic since we got a response
|
|
||||||
mqtt_client.unsubscribe(config.MQTT_TOPIC_HELLO)
|
|
||||||
print("[MQTT Thread] Unsubscribed from topic:", config.MQTT_TOPIC_HELLO)
|
|
||||||
print("[MQTT Thread] Message processing complete.")
|
|
||||||
|
|
||||||
mqtt_client.set_callback(on_mqtt_message)
|
topic = message.get("topic")
|
||||||
|
|
||||||
|
# 1. Orchestrator Hello Response
|
||||||
|
if (
|
||||||
|
topic == config.MQTT_TOPIC_HELLO
|
||||||
|
and payload_data
|
||||||
|
and payload_data.get("id_microwave") == DEVICE_ID
|
||||||
|
):
|
||||||
|
orchestrator_id = payload_data.get("id_orchestrator")
|
||||||
|
print("[MQTT] Hello response received from orchestrator:", orchestrator_id)
|
||||||
|
should_unsubscribe_hello = True
|
||||||
|
|
||||||
|
# 2. Cooking Parameters / Sensor Request
|
||||||
|
elif (
|
||||||
|
topic == config.MQTT_TOPIC_COOKING
|
||||||
|
and payload_data
|
||||||
|
and payload_data.get("id_microwave") == DEVICE_ID
|
||||||
|
):
|
||||||
|
if "cook_time" not in payload_data:
|
||||||
|
print("[MQTT] Sensor data requested! Triggering async publisher...")
|
||||||
|
# Trigger async event instead of calling publish() directly inside lock context!
|
||||||
|
sensor_request_event.set()
|
||||||
|
else:
|
||||||
|
print("[MQTT] Cooking parameters received:", payload_data)
|
||||||
|
if cookingState:
|
||||||
|
cooking_state = cookingState.CookingState(
|
||||||
|
cook_time=payload_data["cook_time"],
|
||||||
|
power_level=payload_data["power_level"],
|
||||||
|
target_temp=payload_data["target_temp"],
|
||||||
|
)
|
||||||
|
cooking_state.set_state_change_callback(on_cooking_state_change)
|
||||||
|
cooking_state.set_state(cookingState.CookingStates.IDLE)
|
||||||
|
|
||||||
|
if uart_device and UARTCommand:
|
||||||
|
uart_device.send_as_command(
|
||||||
|
UARTCommand(UARTCommandType.COOKING_PARAMS, payload_data)
|
||||||
|
)
|
||||||
|
print("[MQTT] Cooking parameters sent to LoRa board over UART.")
|
||||||
|
|
||||||
|
|
||||||
def mqtt_background_thread():
|
# --- DEDICATED ASYNC TASK FOR SENSOR PUBLISHING ---
|
||||||
"""Background MQTT worker handling ALL socket operations safely."""
|
async def sensor_publisher_task():
|
||||||
print("[Thread] Background MQTT worker started.")
|
"""Waits for sensor_request_event, reads hardware, and publishes outside the MQTT lock."""
|
||||||
|
while True:
|
||||||
|
await sensor_request_event.wait()
|
||||||
|
sensor_request_event.clear()
|
||||||
|
|
||||||
|
print("[Sensor Task] Reading temperature sensors...")
|
||||||
|
obj_temp = (
|
||||||
|
mlx_temperature_sensor.read_object_temp()
|
||||||
|
if mlx_temperature_sensor
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
amb_temp = (
|
||||||
|
mlx_temperature_sensor.read_ambient_temp()
|
||||||
|
if mlx_temperature_sensor
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
sensor_payload = payloads.mqtt_sensor_data(DEVICE_ID, obj_temp, amb_temp)
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("[Sensor Task] Publishing sensor data to MQTT...")
|
||||||
|
mqtt_client.publish(
|
||||||
|
config.MQTT_TOPIC_SENSOR, sensor_payload, qos=config.MQTT_QOS
|
||||||
|
)
|
||||||
|
print("[Sensor Task] Sensor data successfully published:", sensor_payload)
|
||||||
|
except Exception as e:
|
||||||
|
print("[Sensor Task] Failed to publish sensor data:", e)
|
||||||
|
|
||||||
|
|
||||||
|
async def uart_task():
|
||||||
|
"""Polls incoming UART messages from the LoRa board using dynamic method fallback."""
|
||||||
|
while True:
|
||||||
|
if uart_device:
|
||||||
|
try:
|
||||||
|
cmd = uart_device.read_as_command()
|
||||||
|
|
||||||
|
if cmd:
|
||||||
|
print("[UART] Command received from LoRa board:", cmd)
|
||||||
|
if (
|
||||||
|
hasattr(cmd, "command_type")
|
||||||
|
and cmd.command_type == UARTCommandType.STATE_UPDATE
|
||||||
|
and on_received_cooking_state_update
|
||||||
|
):
|
||||||
|
on_received_cooking_state_update(
|
||||||
|
cmd.payload.get("state"),
|
||||||
|
cmd.payload.get("is_error", False),
|
||||||
|
cmd.payload.get("is_terminated", False),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print("[UART Task] Error reading command:", e)
|
||||||
|
|
||||||
|
await asyncio.sleep_ms(50)
|
||||||
|
|
||||||
|
|
||||||
|
async def connect_mqtt_async():
|
||||||
|
global mqtt_connected, mqtt_client
|
||||||
|
mqtt_connected = False
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
print("[Thread] Attempting connection to MQTT broker...")
|
print("[MQTT] Connecting to broker with TLS...")
|
||||||
|
# Re-instantiate client to clear old socket buffers
|
||||||
|
gc.collect()
|
||||||
|
mqtt_client = get_mqtt_client(
|
||||||
|
host="192.168.50.1", # TODO : Use config.MQTT_BROKER_HOST instead of hardcoding
|
||||||
|
port=8884,
|
||||||
|
client_id="smartwave-esp32-demo",
|
||||||
|
use_tls=True,
|
||||||
|
cafile=MQTT_CA_FILE,
|
||||||
|
keepalive=30,
|
||||||
|
)
|
||||||
|
mqtt_client.set_callback(on_mqtt_message)
|
||||||
|
|
||||||
mqtt_client.connect()
|
mqtt_client.connect()
|
||||||
print("[Thread] Connected! Subscribing to topic...")
|
print("[MQTT] Connected! Subscribing to topics...")
|
||||||
mqtt_client.subscribe(config.MQTT_TOPIC_COOKING, qos=config.MQTT_QOS)
|
mqtt_client.subscribe(config.MQTT_TOPIC_COOKING, qos=config.MQTT_QOS)
|
||||||
print("[Thread] Successfully subscribed. Setting up poller...")
|
mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS)
|
||||||
|
print("[MQTT] Subscribed successfully!")
|
||||||
poller = select.poll()
|
mqtt_connected = True
|
||||||
poller.register(mqtt_client._client.sock, select.POLLIN)
|
return
|
||||||
|
|
||||||
last_check = time.time()
|
|
||||||
|
|
||||||
while True:
|
|
||||||
# 1. Process outbound messages queued by the main thread
|
|
||||||
while len(msg_queue) > 0:
|
|
||||||
with queue_lock:
|
|
||||||
topic, payload = msg_queue.pop(0)
|
|
||||||
print(f"[Thread] Safely publishing queued message to {topic}...")
|
|
||||||
mqtt_client.publish(topic, payload, qos=config.MQTT_QOS)
|
|
||||||
|
|
||||||
# 2. Check for incoming messages (non-blocking poll)
|
|
||||||
# Shortened timeout to keep the queue responsive
|
|
||||||
events = poller.poll(200)
|
|
||||||
if events:
|
|
||||||
mqtt_client.wait()
|
|
||||||
|
|
||||||
# 3. Handle Keepalive tracking manually
|
|
||||||
if time.time() - last_check >= 15:
|
|
||||||
# print("[Thread] Sending keepalive ping...")
|
|
||||||
mqtt_client._client.ping()
|
|
||||||
last_check = time.time()
|
|
||||||
|
|
||||||
# Small breathe room for the CPU core
|
|
||||||
time.sleep_ms(50)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("[Thread] Connection dropped or error encountered:", e)
|
print("[MQTT] Connection failed:", e)
|
||||||
sys.print_exception(e)
|
sys.print_exception(e)
|
||||||
print("[Thread] Cleaning up socket context. Retrying in 5 seconds...")
|
|
||||||
|
|
||||||
# --- FIX FOR ERROR 23 (SOCKET LEAK) ---
|
|
||||||
# Manually force-kill the underlying socket file descriptor if it exists
|
|
||||||
try:
|
|
||||||
if mqtt_client._client and hasattr(mqtt_client._client, "sock"):
|
|
||||||
if mqtt_client._client.sock is not None:
|
|
||||||
mqtt_client._client.sock.close()
|
|
||||||
except Exception:
|
|
||||||
pass # Already dead or closed
|
|
||||||
|
|
||||||
# Now we let the wrapper do its normal cleanup safely
|
|
||||||
try:
|
try:
|
||||||
mqtt_client.close()
|
mqtt_client.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
time.sleep(5)
|
|
||||||
|
# Force heap cleanup before sleeping
|
||||||
|
del mqtt_client
|
||||||
|
gc.collect()
|
||||||
|
print(f"[MQTT] Free RAM after cleanup: {gc.mem_free()} bytes")
|
||||||
|
print("[MQTT] Retrying connection in 5 seconds...")
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
async def mqtt_poll_task():
|
||||||
|
global mqtt_connected
|
||||||
|
last_ping = time.time()
|
||||||
|
|
||||||
# --- UART BACKGROUND THREAD ---
|
|
||||||
def uart_background_thread():
|
|
||||||
"""Background UART worker handling all serial operations safely."""
|
|
||||||
print("[Thread] Background UART worker started.")
|
|
||||||
|
|
||||||
uart_device = get_uart(uart_id=2, tx_pin=17, rx_pin=16)
|
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
if mqtt_connected:
|
||||||
# 1. Check for incoming messages from the Heltec board
|
try:
|
||||||
while uart_device.any():
|
mqtt_client.poll()
|
||||||
incoming_msg = uart_device.read()
|
now = time.time()
|
||||||
print(f"[Thread] Received from esp-lora over UART: {incoming_msg}")
|
if now - last_ping >= 15:
|
||||||
|
mqtt_client.ping()
|
||||||
|
last_ping = now
|
||||||
|
except OSError as e:
|
||||||
|
print("[MQTT Task] Socket error encountered during poll/ping:", e)
|
||||||
|
mqtt_connected = False
|
||||||
|
await connect_mqtt_async()
|
||||||
|
|
||||||
|
await asyncio.sleep_ms(30)
|
||||||
|
|
||||||
|
|
||||||
|
async def orchestrator_hello_task():
|
||||||
|
global mqtt_connected, should_unsubscribe_hello
|
||||||
|
while True:
|
||||||
|
if orchestrator_id is not None:
|
||||||
|
if should_unsubscribe_hello:
|
||||||
|
try:
|
||||||
|
mqtt_client.unsubscribe(config.MQTT_TOPIC_HELLO)
|
||||||
|
should_unsubscribe_hello = False
|
||||||
|
print("[MQTT] Successfully unsubscribed from hello topic.")
|
||||||
|
except Exception as e:
|
||||||
|
print("[MQTT] Unsubscribe error:", e)
|
||||||
|
|
||||||
|
# Hello successfully acknowledged! Stop looping this task.
|
||||||
|
print("[Hello Task] Orchestrator acknowledged. Stopping hello task.")
|
||||||
|
break
|
||||||
|
|
||||||
# 2. Example: Send data to the Heltec board every 5 seconds
|
if mqtt_connected:
|
||||||
# uart_device.send("Status Check: WiFi Active")
|
print("[Hello Task] Sending initial hello to orchestrator...")
|
||||||
|
try:
|
||||||
time.sleep(5) # Fast responsive polling loop for local UART
|
if mqtt_client is None:
|
||||||
|
print("[Hello Task] MQTT client is None. Attempting to reconnect...")
|
||||||
except Exception as e:
|
await connect_mqtt_async()
|
||||||
print("[Thread] UART error encountered:", e)
|
|
||||||
time.sleep(5)
|
mqtt_client.publish(
|
||||||
|
config.MQTT_TOPIC_HELLO,
|
||||||
|
payloads.mqtt_hello(DEVICE_ID),
|
||||||
|
qos=config.MQTT_QOS,
|
||||||
|
)
|
||||||
|
except OSError as e:
|
||||||
|
print("[Hello Task] Hello publish failed:", e)
|
||||||
|
# mqtt_connected = False
|
||||||
|
|
||||||
# --- Launch background worker ---
|
await asyncio.sleep(config.MQTT_HELLO_INTERVAL)
|
||||||
# _thread.start_new_thread(mqtt_background_thread, ())
|
|
||||||
# _thread.start_new_thread(uart_background_thread, ())
|
|
||||||
|
|
||||||
|
|
||||||
# --- MAIN APPLICATION THREAD (Core 0) ---
|
async def memory_cleanup_task():
|
||||||
print("[Main] Main execution path active.")
|
while True:
|
||||||
time.sleep(2) # Give the thread a moment to initial connect
|
gc.collect()
|
||||||
mqtt_hello_sent_timestamp = -config.MQTT_HELLO_INTERVAL
|
await asyncio.sleep(10)
|
||||||
mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS)
|
|
||||||
|
|
||||||
while True:
|
|
||||||
# MQTT HELLO sent every x seconds until we get a response from the orchestrator
|
# --- MAIN ENTRY POINT ---
|
||||||
if (orchestrator_id == None and -(mqtt_hello_sent_timestamp - time.time()) > config.MQTT_HELLO_INTERVAL):
|
async def main():
|
||||||
print("[Main] Attempting to send initial hello to orchestrator...")
|
global sensor_request_event
|
||||||
queue_publish(config.MQTT_TOPIC_HELLO, payloads.mqtt_hello(DEVICE_ID))
|
print("[Main] Starting application...")
|
||||||
mqtt_hello_sent_timestamp = time.time()
|
|
||||||
pass
|
# Initialize loop-bound events
|
||||||
|
sensor_request_event = asyncio.Event()
|
||||||
# Sensors
|
|
||||||
print(f"[Main] Reading temperature from the gun sensor...")
|
await connect_mqtt_async()
|
||||||
temp = temperature_gun.read_temperature()
|
init_hardware()
|
||||||
print(f"[Main] Temperature read: {temp}°C")
|
|
||||||
|
# Launch background tasks
|
||||||
# 2. Example: Send data to the Heltec board every 5 seconds
|
asyncio.create_task(mqtt_poll_task())
|
||||||
# uart_device.send("Status Check: WiFi Active")
|
asyncio.create_task(orchestrator_hello_task())
|
||||||
time.sleep(1)
|
asyncio.create_task(sensor_publisher_task())
|
||||||
|
asyncio.create_task(uart_task())
|
||||||
|
asyncio.create_task(memory_cleanup_task())
|
||||||
|
|
||||||
|
print("[Main] All tasks running concurrently!")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(3600)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("[Main] Program stopped by user.")
|
||||||
@@ -1 +1 @@
|
|||||||
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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
db.sqlite*
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+311
-145
@@ -1,53 +1,53 @@
|
|||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import threading
|
|
||||||
import queue
|
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
|
import asyncio
|
||||||
|
import requests
|
||||||
|
|
||||||
from orchestrateur.sensors import gps
|
from orchestrateur.sensors import gps
|
||||||
from shared import get_lora, get_mqtt_client, deviceTypes, config, payloads
|
from shared import get_lora, get_mqtt_client, deviceTypes, config, payloads
|
||||||
from shared.logging import log
|
from shared.logging import log
|
||||||
|
from shared.cookingState import CookingStates
|
||||||
|
from shared.lora_device import LoraCommands
|
||||||
from sensors import ultrasonicRanger, temp_hum, button, camera
|
from sensors import ultrasonicRanger, temp_hum, button, camera
|
||||||
|
|
||||||
# --- Read Unique Device ID ---
|
# --- Read Unique Device ID ---
|
||||||
try:
|
def get_device_id():
|
||||||
with open("device_id.txt", "r") as f:
|
for path in ["device_id.txt", "/home/pi/SmartWave/orchestrateur/device_id.txt"]:
|
||||||
DEVICE_ID = f.read().strip()
|
try:
|
||||||
except Exception:
|
with open(path, "r") as f:
|
||||||
try:
|
return f.read().strip()
|
||||||
with open("/home/pi/SmartWave/orchestrateur/device_id.txt", "r") as f:
|
except Exception:
|
||||||
DEVICE_ID = f.read().strip()
|
pass
|
||||||
except Exception:
|
return "RPI_Orchestrateur_Default"
|
||||||
DEVICE_ID = "RPI_Orchestrateur_Default"
|
|
||||||
|
|
||||||
# Thread-safe queue for application messages
|
DEVICE_ID = get_device_id()
|
||||||
data_queue = queue.Queue()
|
|
||||||
|
|
||||||
|
# --- STATE MACHINE DEFINITIONS ---
|
||||||
|
class MicrowaveState:
|
||||||
|
IDLE = "IDLE" # Microwave is empty
|
||||||
|
ANALYZING = "ANALYZING" # Reading sensors & waiting for IR
|
||||||
|
WAITING_FOR_CLOUD = "WAITING_FOR_CLOUD" # Waiting for API parameters
|
||||||
|
COOKING = "COOKING" # Microwave is active
|
||||||
|
DONE = "DONE" # Finished/Stopped, waiting for dish removal
|
||||||
|
|
||||||
|
# Global state trackers
|
||||||
|
microwave_states = {"2": MicrowaveState.IDLE}
|
||||||
|
button_state = False
|
||||||
|
async_event_queue = None
|
||||||
|
# Async synchronization trackers for MQTT IR sensors responses
|
||||||
|
ir_data_cache = {} # mw_id -> dict of IR readings
|
||||||
|
ir_data_events = {} # mw_id -> asyncio.Event()
|
||||||
|
|
||||||
|
|
||||||
|
# --- HARDWARE SETUP ---
|
||||||
lora = get_lora()
|
lora = get_lora()
|
||||||
lora.configure()
|
lora.configure()
|
||||||
|
|
||||||
def lora_listener():
|
|
||||||
"""Background Thread: Listens to LoRa traffic and responds to Heartbeats."""
|
|
||||||
print("Thread Écouteur LoRa démarré.")
|
|
||||||
while True:
|
|
||||||
paquet = lora.receive_packet(timeout_ms=1000)
|
|
||||||
if paquet:
|
|
||||||
donnees = paquet["data"]
|
|
||||||
expediteur_type = donnees.get("type")
|
|
||||||
|
|
||||||
if expediteur_type == deviceTypes.DEVICE_TYPES["MICROWAVE"]:
|
|
||||||
print(f"\n[Thread LoRa] Heartbeat reçu de {donnees.get('id')}")
|
|
||||||
reponse = {
|
|
||||||
"id": DEVICE_ID,
|
|
||||||
"type": deviceTypes.DEVICE_TYPES["ORCHESTRATOR"]
|
|
||||||
}
|
|
||||||
lora.send(reponse)
|
|
||||||
else:
|
|
||||||
data_queue.put({"source": "LoRa", "data": paquet})
|
|
||||||
|
|
||||||
# --- Setup & Connect MQTT ---
|
|
||||||
mqtt_client = get_mqtt_client(
|
mqtt_client = get_mqtt_client(
|
||||||
host="192.168.50.1", # Using explicit gateway IP to dodge Docker loopback blocks
|
host="192.168.50.1",
|
||||||
client_id="smartwave-orchestrateur-"+DEVICE_ID,
|
client_id="smartwave-orchestrateur-" + DEVICE_ID,
|
||||||
use_tls=config.USE_TLS,
|
use_tls=config.USE_TLS,
|
||||||
cafile="/home/pi/SmartWave/orchestrateur/mqtt/certs/ca.crt",
|
cafile="/home/pi/SmartWave/orchestrateur/mqtt/certs/ca.crt",
|
||||||
keepalive=config.MQTT_KEEPALIVE,
|
keepalive=config.MQTT_KEEPALIVE,
|
||||||
@@ -55,139 +55,305 @@ mqtt_client = get_mqtt_client(
|
|||||||
mqtt_client.connect()
|
mqtt_client.connect()
|
||||||
mqtt_client.subscribe(config.MQTT_TOPIC_SENSOR, qos=config.MQTT_QOS)
|
mqtt_client.subscribe(config.MQTT_TOPIC_SENSOR, qos=config.MQTT_QOS)
|
||||||
mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS)
|
mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS)
|
||||||
print(f"Subscribed to topic: {config.MQTT_TOPIC_SENSOR}")
|
|
||||||
|
|
||||||
# --- THE CRUCIAL PAHO FIX ---
|
|
||||||
# Start Paho's internal background thread. This handles all network packets,
|
|
||||||
# automatic keepalive pings, and delivery receipts cleanly.
|
|
||||||
if hasattr(mqtt_client._client, "loop_start"):
|
if hasattr(mqtt_client._client, "loop_start"):
|
||||||
mqtt_client._client.loop_start()
|
mqtt_client._client.loop_start()
|
||||||
print("Paho MQTT asynchronous network loop started.")
|
print("[MQTT] Paho background loop started.")
|
||||||
|
|
||||||
|
# --- BACKGROUND TASKS (PRODUCERS) ---
|
||||||
|
async def lora_listener_task():
|
||||||
|
"""Polls LoRa and pushes to the async queue."""
|
||||||
|
print("[LoRa] Async listener started.")
|
||||||
|
while True:
|
||||||
|
# Run blocking lora receive in a thread to not block asyncio loop
|
||||||
|
paquet = await asyncio.to_thread(lora.receive_reliable, timeout_ms=100)
|
||||||
|
if paquet:
|
||||||
|
await async_event_queue.put({"source": "LoRa", "data": paquet})
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
def mqtt_listener():
|
async def mqtt_listener_task():
|
||||||
"""Background Thread: Constantly inspects incoming MQTT message cache."""
|
"""Polls MQTT cache and pushes to the async queue."""
|
||||||
print("Thread MQTT démarré.")
|
print("[MQTT] Async listener started.")
|
||||||
while True:
|
while True:
|
||||||
message = mqtt_client.get_message()
|
message = mqtt_client.get_message()
|
||||||
|
|
||||||
if message:
|
if message:
|
||||||
# Try to parse the payload as a python dictionary, but if it fails, just print the raw payload
|
|
||||||
try:
|
try:
|
||||||
payload = json.loads(message['payload'])
|
payload = json.loads(message['payload'])
|
||||||
except Exception as e:
|
except Exception:
|
||||||
print(f"Error parsing MQTT payload: {e}")
|
payload = message['payload']
|
||||||
payload = message['payload'] # Fallback to raw payload if parsing fails
|
|
||||||
|
|
||||||
print(f"\n[Thread MQTT] Message reçu : {message}")
|
# --- SAFE TOPIC DECODING ---
|
||||||
data_queue.put({"source": "MQTT", "topic": message['topic'] ,"data": payload})
|
topic = message['topic']
|
||||||
|
if isinstance(topic, bytes):
|
||||||
# Sleep for 100ms. Prevents the thread from turning into an infinite 100% CPU hog.
|
topic = topic.decode('utf-8')
|
||||||
time.sleep(0.2)
|
|
||||||
|
await async_event_queue.put({
|
||||||
|
"source": "MQTT",
|
||||||
|
"topic": topic,
|
||||||
|
"data": payload
|
||||||
|
})
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
# Button
|
|
||||||
button_state = False
|
|
||||||
def button_callback():
|
def button_callback():
|
||||||
|
"""Button physical interrupt callback."""
|
||||||
global button_state
|
global button_state
|
||||||
button_state = not button_state
|
if microwave_states.get("2") == MicrowaveState.COOKING:
|
||||||
print(f"\n[Thread Button] Button state changed to: {button_state}")
|
print("[Button] Toggling pause/resume for microwave '2'.")
|
||||||
|
lora.send_reliable({"id": DEVICE_ID, "microwave_id": "2", "action": LoraCommands.TOGGLE_PAUSE})
|
||||||
button.set_callback(button_callback)
|
else:
|
||||||
|
button_state = not button_state
|
||||||
|
print(f"[Button] Defrost state toggled to: {button_state}")
|
||||||
|
|
||||||
# Launch background monitoring workers
|
button.set_callback(button_callback)
|
||||||
# threading.Thread(target=lora_listener, daemon=True).start()
|
|
||||||
# threading.Thread(target=mqtt_listener, daemon=True).start()
|
|
||||||
# Launch button monitoring thread
|
|
||||||
button.start_button_monitoring_thread()
|
button.start_button_monitoring_thread()
|
||||||
|
|
||||||
print("Orchestrateur prêt. Le main loop est libre.")
|
# --- HARDWARE CONTROLLERS ---
|
||||||
|
def _stop_hardware(microwave_id: str):
|
||||||
|
print(f"[{microwave_id}] /!\ Emergency stop issued to hardware.")
|
||||||
|
# TODO: Add LoRa STOP command here
|
||||||
|
|
||||||
# Sensor reading
|
# --- ASYNC COOKING LOGIC ---
|
||||||
def read_sensors():
|
def read_local_sensors(microwave_id, initial_dish_height):
|
||||||
"""Read all sensors and return a dictionary of their values."""
|
"""Blocking function to read local I2C/SPI sensors. Runs in a thread."""
|
||||||
log("\nLecture des capteurs...")
|
print(f"[{microwave_id}] Reading local physical sensors...")
|
||||||
sensor_data = {}
|
sensor_data = {
|
||||||
|
"microwave_id": microwave_id,
|
||||||
|
"defrost_mode": button_state,
|
||||||
|
"ultrasonic_distance": initial_dish_height # Reuse height from trigger
|
||||||
|
}
|
||||||
|
|
||||||
# Read Ultrasonic Ranger
|
# Temp / Hum (handles DHT error safely)
|
||||||
distance = ultrasonicRanger.get_dish_height()
|
try:
|
||||||
if distance is not None:
|
temp, hum = temp_hum.get_temperature_and_humidity_with_retry()
|
||||||
log(f"\nLecture du capteur Ultrason : {distance}")
|
if temp is not None:
|
||||||
sensor_data["ultrasonic_distance"] = distance
|
sensor_data["temperature"] = temp
|
||||||
|
sensor_data["humidity"] = hum
|
||||||
# Read Temperature and Humidity
|
except Exception as e:
|
||||||
temperature, humidity = temp_hum.get_temperature_and_humidity()
|
log(f"[{microwave_id}] DHT read warning: {e}")
|
||||||
if temperature is not None and humidity is not None:
|
|
||||||
log(f"\nLecture du capteur Temp/Hum : {temperature}, {humidity}")
|
|
||||||
sensor_data["temperature"] = temperature
|
|
||||||
sensor_data["humidity"] = humidity
|
|
||||||
|
|
||||||
|
|
||||||
# Read GPS Data
|
|
||||||
gps_data = gps.get_gps_data()
|
|
||||||
if gps_data:
|
|
||||||
log(f"\nLecture du capteur GPS : {gps_data}")
|
|
||||||
sensor_data["gps"] = gps_data
|
|
||||||
|
|
||||||
# Camera
|
# Camera
|
||||||
picture_bytes = None
|
|
||||||
try:
|
try:
|
||||||
picture_bytes = camera.get_picture()
|
sensor_data["camera_image"] = camera.get_picture()
|
||||||
log(f"\nLecture du capteur Caméra : {len(picture_bytes)} bytes")
|
|
||||||
sensor_data["camera_image"] = picture_bytes
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(f"Error reading camera data: {e}")
|
log(f"[{microwave_id}] Camera read failed: {e}")
|
||||||
|
|
||||||
# Read Button State (last because he can still change state while reading other sensors)
|
|
||||||
sensor_data["button_state"] = button_state
|
|
||||||
|
|
||||||
return sensor_data
|
return sensor_data
|
||||||
|
|
||||||
# --- MAIN EXECUTION LOOP ---
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
# Check for non-heartbeat data
|
|
||||||
try:
|
|
||||||
msg = data_queue.get(block=False)
|
|
||||||
|
|
||||||
# print(msg)
|
|
||||||
|
|
||||||
if msg["source"] == "LoRa":
|
|
||||||
print(f"\n[Main Loop] LoRa : Données traitées : {msg['data']}")
|
|
||||||
elif msg["source"] == "MQTT":
|
|
||||||
if (msg["topic"] == config.MQTT_TOPIC_HELLO.decode('utf-8')):
|
|
||||||
if ("id_orchestrator" in msg["data"] and msg["data"]["id_orchestrator"] == DEVICE_ID):
|
|
||||||
# Do not answer to messages coming from me
|
|
||||||
continue
|
|
||||||
microwave_id = msg["data"]["id_microwave"]
|
|
||||||
print(f"\n[Main Loop] MQTT : Hello reçu de {microwave_id}.")
|
|
||||||
# Responds
|
|
||||||
mqtt_client.publish(config.MQTT_TOPIC_HELLO, payloads.mqtt_hello_ack(DEVICE_ID, microwave_id), qos=config.MQTT_QOS)
|
|
||||||
print(f"[Main Loop] MQTT : Réponse Hello envoyée à {microwave_id}.")
|
|
||||||
# TODO : Save in database
|
|
||||||
|
|
||||||
|
|
||||||
print(f"\n[Main Loop] MQTT : Données traitées : {msg['data']}")
|
|
||||||
except queue.Empty:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# DEBUG : Read sensors
|
|
||||||
sensor_values = read_sensors()
|
|
||||||
if sensor_values:
|
|
||||||
sensor_values_print = sensor_values.copy()
|
|
||||||
if "camera_image" in sensor_values_print:
|
|
||||||
sensor_values_print["camera_image"] = f"<{len(sensor_values_print['camera_image'])} bytes>"
|
|
||||||
print(f"\nCapteurs Données lues : {sensor_values_print}")
|
|
||||||
|
|
||||||
time.sleep(3)
|
|
||||||
|
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
traceback.print_exc()
|
|
||||||
time.sleep(1) # Prevents rapid error logging in case of persistent issues
|
|
||||||
|
|
||||||
# Clean termination
|
async def handle_new_dish(microwave_id, detected_height):
|
||||||
if hasattr(mqtt_client._client, "loop_stop"):
|
"""Triggered when a new dish is placed inside."""
|
||||||
mqtt_client._client.loop_stop()
|
microwave_states[microwave_id] = MicrowaveState.ANALYZING
|
||||||
mqtt_client.close()
|
print(f"\n[{microwave_id}] 🍽️ Dish detected at {detected_height:.1f} cm! Requesting IR from microwave...")
|
||||||
|
|
||||||
|
# 1. Setup synchronization event and clear previous cache for this microwave
|
||||||
|
event = asyncio.Event()
|
||||||
|
ir_data_events[microwave_id] = event
|
||||||
|
ir_data_cache.pop(microwave_id, None)
|
||||||
|
|
||||||
|
# 2. Send IR request to ESP32 via MQTT immediately
|
||||||
|
mqtt_client.publish(
|
||||||
|
config.MQTT_TOPIC_COOKING,
|
||||||
|
payloads.mqtt_cooking_init(microwave_id),
|
||||||
|
qos=config.MQTT_QOS
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Start local sensor reading in parallel
|
||||||
|
sensor_task = asyncio.create_task(asyncio.to_thread(read_local_sensors, microwave_id, detected_height))
|
||||||
|
|
||||||
|
# 4. Wait for local sensors to finish reading
|
||||||
|
sensors_data = await sensor_task
|
||||||
|
|
||||||
|
# Check if dish was removed while reading sensors
|
||||||
|
if microwave_states.get(microwave_id) != MicrowaveState.ANALYZING:
|
||||||
|
print(f"[{microwave_id}] Dish removed during sensor read. Aborting.")
|
||||||
|
ir_data_events.pop(microwave_id, None)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 5. Wait for MQTT IR data (if it already arrived, event.wait() returns instantly)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(event.wait(), timeout=10.0)
|
||||||
|
ir_payload = ir_data_cache.get(microwave_id, {})
|
||||||
|
sensors_data["ir_initial_temp"] = ir_payload.get("dish_temp")
|
||||||
|
sensors_data["ir_ambient_temp"] = ir_payload.get("ambient_temp")
|
||||||
|
print(f"[{microwave_id}] IR data synchronized successfully: {ir_payload}")
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
print(f"[{microwave_id}] ⚠️ Timeout waiting for MQTT IR data from ESP32.")
|
||||||
|
sensors_data["ir_initial_temp"] = None
|
||||||
|
sensors_data["ir_ambient_temp"] = None
|
||||||
|
finally:
|
||||||
|
ir_data_events.pop(microwave_id, None)
|
||||||
|
|
||||||
|
# 6. Dispatch cloud request task
|
||||||
|
asyncio.create_task(request_cloud_cooking_plan(microwave_id, sensors_data))
|
||||||
|
|
||||||
|
async def request_cloud_cooking_plan(microwave_id, sensors_data):
|
||||||
|
"""Sends all data to the cloud and starts the microwave if successful."""
|
||||||
|
microwave_states[microwave_id] = MicrowaveState.WAITING_FOR_CLOUD
|
||||||
|
URL = "https://smartwave.matthiasg.dev/cooking-params"
|
||||||
|
|
||||||
|
# Format image
|
||||||
|
if isinstance(sensors_data.get("camera_image"), bytes):
|
||||||
|
sensors_data["camera_image"] = base64.b64encode(sensors_data["camera_image"]).decode("utf-8")
|
||||||
|
|
||||||
|
print(f"[{microwave_id}] Requesting cooking plan from cloud app...")
|
||||||
|
try:
|
||||||
|
response = await asyncio.to_thread(requests.post, URL, json=sensors_data, timeout=30)
|
||||||
|
|
||||||
|
# Abort if state changed (e.g. user removed dish while waiting for wifi)
|
||||||
|
if microwave_states[microwave_id] != MicrowaveState.WAITING_FOR_CLOUD:
|
||||||
|
print(f"[{microwave_id}] Dish removed during API request. Discarding API plan.")
|
||||||
|
return
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
plan = response.json().get("cook_plan", {})
|
||||||
|
c_time = plan.get("cook_time_seconds")
|
||||||
|
c_power = plan.get("effective_power_watts")
|
||||||
|
c_temp = plan.get("target_temp")
|
||||||
|
|
||||||
|
if c_time is None or c_power is None or c_temp is None:
|
||||||
|
print(f"[{microwave_id}] ❌ Invalid plan received: {response.json()}")
|
||||||
|
microwave_states[microwave_id] = MicrowaveState.DONE # Fail safe
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"[{microwave_id}] Cloud Plan Received! Starting microwave: {c_time}s @ {c_power}W")
|
||||||
|
microwave_states[microwave_id] = MicrowaveState.COOKING
|
||||||
|
mqtt_client.publish(
|
||||||
|
config.MQTT_TOPIC_COOKING,
|
||||||
|
payloads.mqtt_cooking_config(microwave_id, c_time, c_power, c_temp),
|
||||||
|
qos=config.MQTT_QOS
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[{microwave_id}] Cloud API Error: {e}")
|
||||||
|
microwave_states[microwave_id] = MicrowaveState.DONE
|
||||||
|
|
||||||
|
# --- MAIN LOGIC TASKS ---
|
||||||
|
async def process_messages_task():
|
||||||
|
"""Consumes the unified event queue."""
|
||||||
|
while True:
|
||||||
|
msg = await async_event_queue.get()
|
||||||
|
source = msg["source"]
|
||||||
|
data = msg["data"]
|
||||||
|
|
||||||
|
if source == "LoRa":
|
||||||
|
if "new_cooking_state" in data.get("data", {}):
|
||||||
|
mw_id = data["data"].get("id")
|
||||||
|
n_state = data["data"].get("new_cooking_state")
|
||||||
|
print(f"[LoRa] Microwave {mw_id} state changed to: {n_state}")
|
||||||
|
|
||||||
|
if n_state == CookingStates.IDLE and microwave_states.get(mw_id) == MicrowaveState.COOKING:
|
||||||
|
microwave_states[mw_id] = MicrowaveState.DONE
|
||||||
|
print(f"[{mw_id}] Cooking finished. Waiting for user to remove dish.")
|
||||||
|
|
||||||
|
elif source == "MQTT":
|
||||||
|
topic = msg["topic"]
|
||||||
|
|
||||||
|
# Helper to normalize config topics to str
|
||||||
|
def to_str(val):
|
||||||
|
return val.decode('utf-8') if isinstance(val, bytes) else val
|
||||||
|
|
||||||
|
hello_topic = to_str(config.MQTT_TOPIC_HELLO)
|
||||||
|
sensor_topic = to_str(config.MQTT_TOPIC_SENSOR)
|
||||||
|
|
||||||
|
if topic == hello_topic:
|
||||||
|
if data.get("id_orchestrator") != DEVICE_ID:
|
||||||
|
mw_id = data.get("id_microwave")
|
||||||
|
print(f"[MQTT] Hello from {mw_id}. Sending ACK.")
|
||||||
|
mqtt_client.publish(
|
||||||
|
config.MQTT_TOPIC_HELLO,
|
||||||
|
payloads.mqtt_hello_ack(DEVICE_ID, mw_id),
|
||||||
|
qos=config.MQTT_QOS
|
||||||
|
)
|
||||||
|
|
||||||
|
elif topic == sensor_topic:
|
||||||
|
mw_id = str(data.get("id_microwave"))
|
||||||
|
print(f"[MQTT] Sensor data received for microwave {mw_id}: {data}")
|
||||||
|
|
||||||
|
# Store IR data and notify the waiting dish handler
|
||||||
|
ir_data_cache[mw_id] = data
|
||||||
|
if mw_id in ir_data_events:
|
||||||
|
ir_data_events[mw_id].set()
|
||||||
|
|
||||||
|
async def get_filtered_dish_height(samples=3, delay=0.04):
|
||||||
|
"""Reads ultrasonic sensor multiple times and returns the median, discarding invalid zeros."""
|
||||||
|
valid_samples = []
|
||||||
|
for _ in range(samples):
|
||||||
|
h = await asyncio.to_thread(ultrasonicRanger.get_dish_height)
|
||||||
|
# Discard 0.0 or near-zero timeout glitches
|
||||||
|
if h is not None and h > 0.5:
|
||||||
|
valid_samples.append(h)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
if valid_samples:
|
||||||
|
valid_samples.sort()
|
||||||
|
return valid_samples[len(valid_samples) // 2] # Median sample
|
||||||
|
return None # All reads failed or out of range
|
||||||
|
|
||||||
|
|
||||||
|
async def monitor_dish_height_task():
|
||||||
|
"""Monitors presence of dish with hysteresis and debouncing."""
|
||||||
|
mw_id = "2"
|
||||||
|
consecutive_present = 0
|
||||||
|
consecutive_absent = 0
|
||||||
|
REQUIRED_STABLE_READS = 3 # Must see 3 stable states in a row (~1 second)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
dist = await get_filtered_dish_height()
|
||||||
|
current_state = microwave_states.get(mw_id, MicrowaveState.IDLE)
|
||||||
|
|
||||||
|
if dist is not None:
|
||||||
|
# Hysteresis Thresholds:
|
||||||
|
# - Must be > 2.5 cm to detect dish insertion
|
||||||
|
# - Must be < 1.2 cm to detect dish removal
|
||||||
|
if dist > 2.5:
|
||||||
|
consecutive_present += 1
|
||||||
|
consecutive_absent = 0
|
||||||
|
elif dist < 1.2:
|
||||||
|
consecutive_absent += 1
|
||||||
|
consecutive_present = 0
|
||||||
|
else:
|
||||||
|
# Dead-zone (1.2cm to 2.5cm) -> Noise buffer
|
||||||
|
consecutive_present = 0
|
||||||
|
consecutive_absent = 0
|
||||||
|
|
||||||
|
# --- DISH INSERTED CONFIRMED ---
|
||||||
|
if consecutive_present >= REQUIRED_STABLE_READS and current_state == MicrowaveState.IDLE:
|
||||||
|
consecutive_present = 0
|
||||||
|
asyncio.create_task(handle_new_dish(mw_id, dist))
|
||||||
|
|
||||||
|
# --- DISH REMOVED CONFIRMED ---
|
||||||
|
elif consecutive_absent >= REQUIRED_STABLE_READS and current_state != MicrowaveState.IDLE:
|
||||||
|
consecutive_absent = 0
|
||||||
|
print(f"\n[{mw_id}] Dish Removed! Resetting state to IDLE.")
|
||||||
|
microwave_states[mw_id] = MicrowaveState.IDLE
|
||||||
|
if current_state == MicrowaveState.COOKING:
|
||||||
|
_stop_hardware(mw_id)
|
||||||
|
# Remove from IR cache and events
|
||||||
|
ir_data_cache.pop(mw_id, None)
|
||||||
|
ir_data_events.pop(mw_id, None)
|
||||||
|
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
# --- BOOTSTRAP ---
|
||||||
|
async def main():
|
||||||
|
global async_event_queue
|
||||||
|
print("🚀 Orchestrateur Asyncio prêt. Lancement des tâches...")
|
||||||
|
|
||||||
|
async_event_queue = asyncio.Queue()
|
||||||
|
|
||||||
|
await asyncio.gather(
|
||||||
|
lora_listener_task(),
|
||||||
|
mqtt_listener_task(),
|
||||||
|
process_messages_task(),
|
||||||
|
monitor_dish_height_task()
|
||||||
|
)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
asyncio.run(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nArrêt manuel.")
|
||||||
|
finally:
|
||||||
|
if hasattr(mqtt_client._client, "loop_stop"):
|
||||||
|
mqtt_client._client.loop_stop()
|
||||||
|
mqtt_client.close()
|
||||||
@@ -11,7 +11,8 @@ grovepi.pinMode(button, "INPUT")
|
|||||||
button_callback = None
|
button_callback = None
|
||||||
|
|
||||||
def read_button_state():
|
def read_button_state():
|
||||||
if not grove_lock.acquire(timeout=0.05):
|
# Increase timeout slightly so the button thread can wait for long I2C sensor reads to finish
|
||||||
|
if not grove_lock.acquire(timeout=0.2):
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
return grovepi.digitalRead(button)
|
return grovepi.digitalRead(button)
|
||||||
@@ -26,15 +27,18 @@ def monitor_button():
|
|||||||
last_button_state = button_switch_state
|
last_button_state = button_switch_state
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
time.sleep(0.04)
|
|
||||||
|
|
||||||
current_state = read_button_state()
|
current_state = read_button_state()
|
||||||
|
|
||||||
if current_state is not None:
|
if current_state is not None:
|
||||||
|
# Rising edge detection (0 -> 1 transition)
|
||||||
if current_state == 1 and last_button_state == 0:
|
if current_state == 1 and last_button_state == 0:
|
||||||
if button_callback:
|
if button_callback:
|
||||||
button_callback()
|
button_callback()
|
||||||
last_button_state = current_state
|
last_button_state = current_state
|
||||||
|
time.sleep(0.02) # Fast 20ms poll when lock is clear
|
||||||
|
else:
|
||||||
|
# Lock was busy; retry quickly without updating last_button_state
|
||||||
|
time.sleep(0.01)
|
||||||
|
|
||||||
def start_button_monitoring_thread():
|
def start_button_monitoring_thread():
|
||||||
threading.Thread(target=monitor_button, daemon=True).start()
|
threading.Thread(target=monitor_button, daemon=True).start()
|
||||||
|
|||||||
@@ -15,8 +15,9 @@
|
|||||||
import time,sys
|
import time,sys
|
||||||
import RPi.GPIO as GPIO
|
import RPi.GPIO as GPIO
|
||||||
import smbus
|
import smbus
|
||||||
|
from shared import config
|
||||||
|
|
||||||
debug = 0
|
debug = config.DEBUG
|
||||||
# use the bus that matches your raspi version
|
# use the bus that matches your raspi version
|
||||||
rev = GPIO.RPI_REVISION
|
rev = GPIO.RPI_REVISION
|
||||||
if rev == 2 or rev == 3:
|
if rev == 2 or rev == 3:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import grovepi
|
import grovepi
|
||||||
import math
|
import math
|
||||||
|
import time
|
||||||
from sensors.lock import grove_lock
|
from sensors.lock import grove_lock
|
||||||
|
|
||||||
# Connect the Grove Temperature & Humidity Sensor Pro to digital port D3
|
# Connect the Grove Temperature & Humidity Sensor Pro to digital port D3
|
||||||
@@ -20,3 +21,11 @@ def get_temperature_and_humidity():
|
|||||||
else:
|
else:
|
||||||
print("Error reading from DHT sensor")
|
print("Error reading from DHT sensor")
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
def get_temperature_and_humidity_with_retry(max_retries=3):
|
||||||
|
for _ in range(max_retries): # Try up to max_retries times
|
||||||
|
temp, humidity = get_temperature_and_humidity()
|
||||||
|
if temp is not None and humidity is not None:
|
||||||
|
return temp, humidity
|
||||||
|
time.sleep(1) # Wait a bit before retrying
|
||||||
|
return None, None
|
||||||
@@ -7,7 +7,7 @@ lora.configure()
|
|||||||
print("Raspberry Pi : En attente active de JSON...")
|
print("Raspberry Pi : En attente active de JSON...")
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
paquet = lora.receive_packet(timeout_ms=5000)
|
paquet = lora.receive_reliable(timeout_ms=5000)
|
||||||
if paquet:
|
if paquet:
|
||||||
# Plus besoin de décoder du HEX ou de parser du JSON manuellement !
|
# Plus besoin de décoder du HEX ou de parser du JSON manuellement !
|
||||||
groupe = paquet['group']
|
groupe = paquet['group']
|
||||||
|
|||||||
@@ -4,6 +4,17 @@
|
|||||||
import shared.deviceTypes as deviceTypes
|
import shared.deviceTypes as deviceTypes
|
||||||
import shared.config as config
|
import shared.config as config
|
||||||
import shared.payloads as payloads
|
import shared.payloads as payloads
|
||||||
|
import shared.cookingState as cookingState
|
||||||
|
import shared.safeQueue as safeQueue
|
||||||
|
try:
|
||||||
|
import shared.lora_device as lora_device
|
||||||
|
except ImportError:
|
||||||
|
pass # No need
|
||||||
|
try:
|
||||||
|
import shared.uart_comm as uart_comm
|
||||||
|
except ImportError:
|
||||||
|
pass # No need as we are on the RPI
|
||||||
|
import shared.sensors
|
||||||
|
|
||||||
def get_lora(*args, **kwargs):
|
def get_lora(*args, **kwargs):
|
||||||
from .lora_device import get_lora_device
|
from .lora_device import get_lora_device
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
DEBUG=True
|
DEBUG=True
|
||||||
|
|
||||||
# LoRa
|
# LoRa
|
||||||
HEARTBEAT_INTERVAL = 30
|
LORA_HEARTBEAT_INTERVAL = 30
|
||||||
|
|
||||||
# MQTT
|
# MQTT
|
||||||
MQTT_BROKER_HOST = "192.168.50.1"
|
MQTT_BROKER_HOST = "192.168.50.1"
|
||||||
|
|||||||
@@ -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
@@ -1,5 +1,6 @@
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
import random
|
||||||
|
|
||||||
IS_MICROPYTHON = sys.implementation.name == 'micropython'
|
IS_MICROPYTHON = sys.implementation.name == 'micropython'
|
||||||
|
|
||||||
@@ -8,49 +9,253 @@ if IS_MICROPYTHON:
|
|||||||
from machine import Pin, SPI
|
from machine import Pin, SPI
|
||||||
import ubinascii
|
import ubinascii
|
||||||
import ujson as json
|
import ujson as json
|
||||||
|
else:
|
||||||
|
import threading
|
||||||
|
import serial
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
# --- BASE RELIABLE LORA DEVICE ---
|
||||||
|
class BaseLoraDevice:
|
||||||
|
"""Base class providing automatic ACK generation, retries, and duplicate filtering."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.processed_msg_ids = set()
|
||||||
|
self.received_acks = set()
|
||||||
|
self.pending_rx_queue = []
|
||||||
|
self.default_group = 2
|
||||||
|
|
||||||
|
def _generate_msg_id(self):
|
||||||
|
return random.getrandbits(16)
|
||||||
|
|
||||||
|
def _send_ack(self, ack_id):
|
||||||
|
"""Sends an immediate acknowledgement packet back to the sender."""
|
||||||
|
print(f"[ReliableLoRa] -> Triggering ACK send for msg_id: {ack_id}")
|
||||||
|
if IS_MICROPYTHON:
|
||||||
|
time.sleep_ms(10)
|
||||||
|
else:
|
||||||
|
time.sleep(0.01)
|
||||||
|
|
||||||
|
ack_payload = {"_type": "_ack", "_ack_id": ack_id}
|
||||||
|
self.send(ack_payload)
|
||||||
|
|
||||||
|
def _process_incoming_packet(self, packet):
|
||||||
|
"""Internal packet processor: handles ACKs and deduplication."""
|
||||||
|
if not packet or packet.get("raw"):
|
||||||
|
return packet
|
||||||
|
|
||||||
|
data = packet.get("data")
|
||||||
|
if isinstance(data, dict):
|
||||||
|
# 1. Handle incoming ACK response
|
||||||
|
if data.get("_type") == "_ack":
|
||||||
|
ack_id = data.get("_ack_id")
|
||||||
|
print(f"[ReliableLoRa] <- SUCCESSFULLY MATCHED ACK ID: {ack_id}")
|
||||||
|
if ack_id is not None:
|
||||||
|
self.received_acks.add(ack_id)
|
||||||
|
if len(self.received_acks) > 100:
|
||||||
|
self.received_acks.clear()
|
||||||
|
return None # Drop internal protocol message from user queue
|
||||||
|
|
||||||
|
# 2. Handle incoming command expecting an ACK
|
||||||
|
msg_id = data.get("_msg_id")
|
||||||
|
if msg_id is not None:
|
||||||
|
print(f"[ReliableLoRa] <- Received packet with msg_id {msg_id}. Queuing ACK.")
|
||||||
|
self._send_ack(msg_id)
|
||||||
|
|
||||||
|
if msg_id in self.processed_msg_ids:
|
||||||
|
print(f"[ReliableLoRa] Discarding duplicate retry for msg_id {msg_id}")
|
||||||
|
return None # Discard duplicate retry
|
||||||
|
|
||||||
|
self.processed_msg_ids.add(msg_id)
|
||||||
|
if len(self.processed_msg_ids) > 100:
|
||||||
|
self.processed_msg_ids.clear()
|
||||||
|
|
||||||
|
return packet
|
||||||
|
|
||||||
|
def send_reliable(self, payload, max_retries=4, ack_timeout=2.5):
|
||||||
|
"""Sends a payload and retries until an ACK is received or max retries are reached."""
|
||||||
|
lock = getattr(self, 'lock', None)
|
||||||
|
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
payload = dict(payload)
|
||||||
|
else:
|
||||||
|
payload = {"data": payload}
|
||||||
|
|
||||||
|
msg_id = self._generate_msg_id()
|
||||||
|
payload["_msg_id"] = msg_id
|
||||||
|
|
||||||
|
print(f"\n[ReliableLoRa] === Starting send_reliable for msg_id {msg_id} ===")
|
||||||
|
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
print(f"[ReliableLoRa] Attempt {attempt + 1}/{max_retries} transmitting msg_id {msg_id}")
|
||||||
|
self.send(payload)
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
while (time.time() - start_time) < ack_timeout:
|
||||||
|
if lock: lock.acquire()
|
||||||
|
try:
|
||||||
|
if msg_id in self.received_acks:
|
||||||
|
self.received_acks.remove(msg_id)
|
||||||
|
print(f"[ReliableLoRa] === ACK received for msg_id {msg_id} on attempt {attempt + 1} ===")
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
if lock: lock.release()
|
||||||
|
|
||||||
|
packet = self.receive_packet(timeout_ms=500)
|
||||||
|
if packet:
|
||||||
|
print(f"[ReliableLoRa] Received raw packet while waiting for ACK: {packet}")
|
||||||
|
if lock: lock.acquire()
|
||||||
|
try:
|
||||||
|
filtered_packet = self._process_incoming_packet(packet)
|
||||||
|
if filtered_packet:
|
||||||
|
self.pending_rx_queue.append(filtered_packet)
|
||||||
|
finally:
|
||||||
|
if lock: lock.release()
|
||||||
|
|
||||||
|
if lock: lock.acquire()
|
||||||
|
try:
|
||||||
|
if msg_id in self.received_acks:
|
||||||
|
self.received_acks.remove(msg_id)
|
||||||
|
print(f"[ReliableLoRa] === ACK received for msg_id {msg_id} after poll ===")
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
if lock: lock.release()
|
||||||
|
|
||||||
|
print(f"[ReliableLoRa] Attempt {attempt + 1} timed out waiting for ACK for msg_id {msg_id}")
|
||||||
|
|
||||||
|
print(f"[ReliableLoRa] ERROR: Failed to receive ACK for msg_id {msg_id} after {max_retries} attempts.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def receive_reliable(self, timeout_ms=1000):
|
||||||
|
"""Receives a packet, automatically sending ACKs and filtering duplicate retries."""
|
||||||
|
if len(self.pending_rx_queue) > 0:
|
||||||
|
return self.pending_rx_queue.pop(0)
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
timeout_s = timeout_ms / 1000.0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
remaining_ms = int((timeout_s - elapsed) * 1000)
|
||||||
|
if remaining_ms <= 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
poll_time = max(50, min(remaining_ms, 300))
|
||||||
|
packet = self.receive_packet(timeout_ms=poll_time)
|
||||||
|
if packet:
|
||||||
|
filtered_packet = self._process_incoming_packet(packet)
|
||||||
|
if filtered_packet:
|
||||||
|
return filtered_packet
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
if IS_MICROPYTHON:
|
||||||
# --- PILOTE SPI DIRECT (ESP32 / Heltec V3) ---
|
# --- PILOTE SPI DIRECT (ESP32 / Heltec V3) ---
|
||||||
class LoraHardwareSPI:
|
class LoraHardwareSPI(BaseLoraDevice):
|
||||||
def __init__(self, spi_bus=1, clk=9, mosi=10, miso=11, cs=8, irq=14, rst=12, gpio=13):
|
def __init__(self, spi_bus=1, clk=9, mosi=10, miso=11, cs=8, irq=14, rst=12, gpio=13):
|
||||||
from sx1262 import SX1262
|
super().__init__()
|
||||||
self.lora = SX1262(
|
self._pins = {
|
||||||
spi_bus=spi_bus, clk=clk, mosi=mosi, miso=miso,
|
"spi_bus": spi_bus, "clk": clk, "mosi": mosi, "miso": miso,
|
||||||
cs=cs, irq=irq, rst=rst, gpio=gpio
|
"cs": cs, "irq": irq, "rst": rst, "gpio": gpio
|
||||||
)
|
}
|
||||||
self.default_group = 2 # On définit le groupe par défaut ici
|
self._cfg = {"freq": 868.1, "bw": 125.0, "sf": 7, "cr": 5, "power": 14}
|
||||||
self.lock = _thread.allocate_lock() # Création du verrou
|
self.lock = _thread.allocate_lock()
|
||||||
|
self.lora = None
|
||||||
|
self.reset_hardware()
|
||||||
|
|
||||||
|
def reset_hardware(self):
|
||||||
|
"""Resets SX1262 hardware and recreates driver instance."""
|
||||||
|
with self.lock:
|
||||||
|
try:
|
||||||
|
irq_pin = Pin(self._pins["irq"], Pin.IN)
|
||||||
|
irq_pin.irq(handler=None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
rst_pin = Pin(self._pins["rst"], Pin.OUT)
|
||||||
|
rst_pin.value(0)
|
||||||
|
time.sleep_ms(30)
|
||||||
|
rst_pin.value(1)
|
||||||
|
time.sleep_ms(50)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.lora = None
|
||||||
|
time.sleep_ms(50)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from sx1262 import SX1262
|
||||||
|
new_instance = SX1262(**self._pins)
|
||||||
|
new_instance.begin(
|
||||||
|
freq=self._cfg["freq"], bw=self._cfg["bw"], sf=self._cfg["sf"],
|
||||||
|
cr=self._cfg["cr"], power=self._cfg["power"],
|
||||||
|
useRegulatorLDO=False, crcOn=True, preambleLength=8, implicit=False
|
||||||
|
)
|
||||||
|
# SyncWord 0x12 = Decimal 18
|
||||||
|
new_instance.setSyncWord(0x12)
|
||||||
|
self.lora = new_instance
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[LoRa SPI] Initialization error: {e}")
|
||||||
|
|
||||||
def configure(self, freq=868.1, bw=125.0, sf=7, cr=5, power=14):
|
def configure(self, freq=868.1, bw=125.0, sf=7, cr=5, power=14):
|
||||||
self.lora.begin(
|
self._cfg = {"freq": freq, "bw": bw, "sf": sf, "cr": cr, "power": power}
|
||||||
freq=freq, bw=bw, sf=sf, cr=cr, power=power,
|
if self.lora is None:
|
||||||
useRegulatorLDO=False, crcOn=True, preambleLength=8, implicit=False
|
self.reset_hardware()
|
||||||
)
|
else:
|
||||||
self.lora.setSyncWord(0x14)
|
with self.lock:
|
||||||
|
try:
|
||||||
|
self.lora.begin(
|
||||||
|
freq=freq, bw=bw, sf=sf, cr=cr, power=power,
|
||||||
|
useRegulatorLDO=False, crcOn=True, preambleLength=8, implicit=False
|
||||||
|
)
|
||||||
|
self.lora.setSyncWord(0x12)
|
||||||
|
except Exception:
|
||||||
|
self.reset_hardware()
|
||||||
|
|
||||||
def send(self, payload, group=None):
|
def send(self, payload, group=None):
|
||||||
"""Encode la payload en JSON si nécessaire, et injecte automatiquement l'octet de groupe."""
|
"""Encodes payload into JSON and prepends group byte."""
|
||||||
with self.lock:
|
with self.lock:
|
||||||
|
if self.lora is None:
|
||||||
|
return
|
||||||
|
|
||||||
if group is None:
|
if group is None:
|
||||||
group = self.default_group
|
group = self.default_group
|
||||||
|
|
||||||
# Si c'est un dictionnaire ou une liste, on le convertit en JSON textuel
|
|
||||||
if isinstance(payload, (dict, list)):
|
if isinstance(payload, (dict, list)):
|
||||||
payload = json.dumps(payload)
|
payload = json.dumps(payload)
|
||||||
|
|
||||||
if isinstance(payload, str):
|
if isinstance(payload, str):
|
||||||
payload = payload.encode('utf-8')
|
payload = payload.encode('utf-8')
|
||||||
|
|
||||||
# Insertion automatique de l'octet de groupe au tout début de la trame physique
|
|
||||||
paquet_physique = bytes([group]) + payload
|
paquet_physique = bytes([group]) + payload
|
||||||
self.lora.send(paquet_physique)
|
try:
|
||||||
|
self.lora.send(paquet_physique)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[LoRa SPI] Send error: {e}")
|
||||||
|
|
||||||
def receive_packet(self, timeout_ms=1000):
|
def receive_packet(self, timeout_ms=500):
|
||||||
"""Écoute, nettoie, extrait le groupe, gère le HEX et parse le JSON."""
|
"""Listens on SPI bus with auto-detection for JSON vs. Grouped headers."""
|
||||||
with self.lock:
|
with self.lock:
|
||||||
data, state = self.lora.recv(len=0, timeout_en=True, timeout_ms=timeout_ms)
|
if self.lora is None:
|
||||||
if state == 0 and len(data) > 1:
|
return None
|
||||||
group = data[0]
|
|
||||||
payload_brute = data[1:].strip(b'\x00 \r\n\t')
|
try:
|
||||||
|
data, state = self.lora.recv(len=0, timeout_en=True, timeout_ms=timeout_ms)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[LoRa SPI] Recv error caught: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if state == 0 and data is not None and len(data) > 0:
|
||||||
|
if data[0] in (0x7B, 0x5B): # Starts with '{' or '['
|
||||||
|
group = self.default_group
|
||||||
|
payload_brute = data.strip(b'\x00 \r\n\t')
|
||||||
|
elif len(data) > 1:
|
||||||
|
group = data[0]
|
||||||
|
payload_brute = data[1:].strip(b'\x00 \r\n\t')
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
text = payload_brute.decode('utf-8').strip('\x00 \r\n\t')
|
text = payload_brute.decode('utf-8').strip('\x00 \r\n\t')
|
||||||
@@ -76,13 +281,10 @@ if IS_MICROPYTHON:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
else:
|
else:
|
||||||
import threading
|
|
||||||
import serial
|
|
||||||
import json
|
|
||||||
|
|
||||||
# --- PILOTE SÉRIE (Raspberry Pi / Dragino LA66) ---
|
# --- PILOTE SÉRIE (Raspberry Pi / Dragino LA66) ---
|
||||||
class LoraSerialAT:
|
class LoraSerialAT(BaseLoraDevice):
|
||||||
def __init__(self, port):
|
def __init__(self, port):
|
||||||
|
super().__init__()
|
||||||
self.port = port
|
self.port = port
|
||||||
self.ser = serial.Serial(
|
self.ser = serial.Serial(
|
||||||
port=self.port,
|
port=self.port,
|
||||||
@@ -95,37 +297,60 @@ else:
|
|||||||
self.ser.reset_input_buffer()
|
self.ser.reset_input_buffer()
|
||||||
self.ser.reset_output_buffer()
|
self.ser.reset_output_buffer()
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
def configure(self, **kwargs):
|
|
||||||
pass
|
# Initial configuration
|
||||||
|
self.configure(freq=868.1, sf=7, bw=125)
|
||||||
|
|
||||||
def send(self, payload):
|
def _send_at_cmd(self, cmd, wait_time=0.15):
|
||||||
"""Encode automatiquement la payload en HEX pour l'envoi via la clé."""
|
"""Helper to send AT command and purge response buffer."""
|
||||||
|
self.ser.write(f"{cmd}\r\n".encode('utf-8'))
|
||||||
|
time.sleep(wait_time)
|
||||||
|
resp = ""
|
||||||
|
while self.ser.in_waiting > 0:
|
||||||
|
resp += self.ser.readline().decode('utf-8', errors='ignore')
|
||||||
|
return resp
|
||||||
|
|
||||||
|
def configure(self, freq=868.1, sf=7, bw=125):
|
||||||
|
"""Configures LA66 frequency, SF, BW, SyncWord, CRC, and continuous RX mode."""
|
||||||
with self.lock:
|
with self.lock:
|
||||||
|
freq_hz = int(freq * 1000000)
|
||||||
|
bw_code = 0 if bw == 125 else 1
|
||||||
|
|
||||||
|
# Parameters: Freq, SF, BW, CR(0=4/5), Preamble(8), Header(1=Explicit), CRC(1=ON), IQ(0=Standard), NetMode(0=P2P), Power(14), SyncWord(18=0x12), Format(0), Type(1)
|
||||||
|
at_cfg_cmd = f"AT+CFG={freq_hz},{sf},{bw_code},0,8,1,1,0,0,14,18,0,1"
|
||||||
|
self._send_at_cmd(at_cfg_cmd, wait_time=0.2)
|
||||||
|
|
||||||
|
# Fallback standalone commands
|
||||||
|
self._send_at_cmd("AT+SYNCWORD=18", wait_time=0.1)
|
||||||
|
self._send_at_cmd("AT+PRECV=65535", wait_time=0.1)
|
||||||
|
self.ser.reset_input_buffer()
|
||||||
|
|
||||||
|
def send(self, payload, group=None):
|
||||||
|
"""Encodes payload into HEX AT command and re-enables continuous RX."""
|
||||||
|
with self.lock:
|
||||||
|
if group is None:
|
||||||
|
group = self.default_group
|
||||||
|
|
||||||
if isinstance(payload, (dict, list)):
|
if isinstance(payload, (dict, list)):
|
||||||
payload = json.dumps(payload)
|
payload = json.dumps(payload)
|
||||||
|
|
||||||
if isinstance(payload, str):
|
if isinstance(payload, str):
|
||||||
payload = payload.encode('utf-8')
|
payload = payload.encode('utf-8')
|
||||||
|
|
||||||
hex_payload = payload.hex()
|
paquet_physique = bytes([group]) + payload
|
||||||
|
hex_payload = paquet_physique.hex()
|
||||||
self.ser.reset_input_buffer()
|
self.ser.reset_input_buffer()
|
||||||
|
|
||||||
# La clé ajoute d'elle-même l'octet de groupe configuré dans ses registres
|
print(f"[RPi LoRa Serial] Transmitting HEX payload: {hex_payload}")
|
||||||
cmd = f"AT+SEND=1,{hex_payload},1,3\r\n"
|
cmd = f"AT+PSEND={hex_payload}"
|
||||||
# print(f"RPI : Envoi de la commande HEX -> AT+SEND=1,[HEX_DATA],1,3")
|
resp = self._send_at_cmd(cmd, wait_time=0.25) # Wait for RF TX to finish
|
||||||
self.ser.write(cmd.encode('utf-8'))
|
print(f"[RPi LoRa Serial] AT+PSEND response: {resp}")
|
||||||
|
|
||||||
time.sleep(0.2)
|
# Re-enable continuous receive mode after transmission completes
|
||||||
response = ""
|
self._send_at_cmd("AT+PRECV=65535", wait_time=0.05)
|
||||||
start_wait = time.time()
|
|
||||||
while (time.time() - start_wait) < 1.5:
|
|
||||||
if self.ser.in_waiting > 0:
|
|
||||||
response += self.ser.readline().decode('utf-8', errors='ignore')
|
|
||||||
time.sleep(0.05)
|
|
||||||
|
|
||||||
# print(f"[RPI LA66 TX STATUS] :\n{response.strip()}")
|
|
||||||
|
|
||||||
def receive_packet(self, timeout_ms=5000):
|
def receive_packet(self, timeout_ms=500):
|
||||||
|
"""Reads incoming serial lines from LA66 stick with robust format parsing."""
|
||||||
with self.lock:
|
with self.lock:
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
timeout_s = timeout_ms / 1000.0
|
timeout_s = timeout_ms / 1000.0
|
||||||
@@ -136,18 +361,38 @@ else:
|
|||||||
if line:
|
if line:
|
||||||
payload_bytes = None
|
payload_bytes = None
|
||||||
|
|
||||||
if "(HEX:)" in line:
|
# Robust parsing for LA66 response variants (+RECV:, +RCV=, +DRX:, HEX:, Data:)
|
||||||
|
if "+RECV:" in line:
|
||||||
|
parts = line.split("+RECV:")[1].strip().split(",")
|
||||||
|
hex_str = parts[2].strip() if len(parts) >= 3 else parts[0].strip()
|
||||||
|
try: payload_bytes = bytes.fromhex(hex_str)
|
||||||
|
except ValueError: pass
|
||||||
|
elif "+RCV=" in line:
|
||||||
|
parts = line.split("+RCV=")[1].strip().split(",")
|
||||||
|
if len(parts) >= 4:
|
||||||
|
try: payload_bytes = bytes.fromhex(parts[3].strip())
|
||||||
|
except ValueError: pass
|
||||||
|
elif "+DRX:" in line:
|
||||||
|
parts = line.split("+DRX:")[1].strip().split(",")
|
||||||
|
if len(parts) >= 2:
|
||||||
|
try: payload_bytes = bytes.fromhex(parts[1].strip())
|
||||||
|
except ValueError: pass
|
||||||
|
elif "(HEX:)" in line:
|
||||||
hex_part = line.split("(HEX:)")[1].strip().replace(" ", "")
|
hex_part = line.split("(HEX:)")[1].strip().replace(" ", "")
|
||||||
try:
|
try: payload_bytes = bytes.fromhex(hex_part)
|
||||||
payload_bytes = bytes.fromhex(hex_part)
|
except ValueError: pass
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
elif "Data:" in line:
|
elif "Data:" in line:
|
||||||
payload_bytes = line.split("Data:")[1].strip().encode('utf-8')
|
payload_bytes = line.split("Data:")[1].strip().encode('utf-8')
|
||||||
|
|
||||||
if payload_bytes and len(payload_bytes) > 1:
|
if payload_bytes and len(payload_bytes) > 0:
|
||||||
group = payload_bytes[0]
|
if payload_bytes[0] in (0x7B, 0x5B):
|
||||||
payload_clean = payload_bytes[1:].strip(b'\x00 \r\n\t')
|
group = self.default_group
|
||||||
|
payload_clean = payload_bytes.strip(b'\x00 \r\n\t')
|
||||||
|
elif len(payload_bytes) > 1:
|
||||||
|
group = payload_bytes[0]
|
||||||
|
payload_clean = payload_bytes[1:].strip(b'\x00 \r\n\t')
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
text = payload_clean.decode('utf-8').strip('\x00 \r\n\t')
|
text = payload_clean.decode('utf-8').strip('\x00 \r\n\t')
|
||||||
@@ -180,4 +425,10 @@ def get_lora_device(port_or_pins=None):
|
|||||||
return LoraHardwareSPI(**pins)
|
return LoraHardwareSPI(**pins)
|
||||||
else:
|
else:
|
||||||
port = port_or_pins if port_or_pins else "/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0"
|
port = port_or_pins if port_or_pins else "/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0"
|
||||||
return LoraSerialAT(port)
|
return LoraSerialAT(port)
|
||||||
|
|
||||||
|
|
||||||
|
class LoraCommands:
|
||||||
|
PING = "ping"
|
||||||
|
COOKING_STATE_UPDATE = "cooking_state_update"
|
||||||
|
TOGGLE_PAUSE = "toggle_pause"
|
||||||
+106
-34
@@ -11,13 +11,10 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
try:
|
try:
|
||||||
from umqtt.simple import MQTTClient as _MQTTClient
|
from umqtt.simple import MQTTClient as _MQTTClient
|
||||||
|
import _thread
|
||||||
|
import gc
|
||||||
BACKEND_NAME = "umqtt.simple"
|
BACKEND_NAME = "umqtt.simple"
|
||||||
IS_MICROPYTHON = True
|
IS_MICROPYTHON = True
|
||||||
# except ImportError:
|
|
||||||
# try:
|
|
||||||
# from umqtt.robust import MQTTClient as _MQTTClient
|
|
||||||
# BACKEND_NAME = "umqtt.robust"
|
|
||||||
# IS_MICROPYTHON = True
|
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
raise ImportError("No MQTT client found. Expected paho.mqtt or umqtt.") from exc
|
raise ImportError("No MQTT client found. Expected paho.mqtt or umqtt.") from exc
|
||||||
|
|
||||||
@@ -79,6 +76,11 @@ class BrokerClient:
|
|||||||
self._client = None
|
self._client = None
|
||||||
self._callback = None
|
self._callback = None
|
||||||
self._messages = []
|
self._messages = []
|
||||||
|
self._cadata = None # Cache cert bytes to prevent heap fragmentation
|
||||||
|
|
||||||
|
# Thread safety lock for MicroPython socket reads/writes
|
||||||
|
if IS_MICROPYTHON:
|
||||||
|
self._lock = _thread.allocate_lock()
|
||||||
|
|
||||||
def set_callback(self, callback):
|
def set_callback(self, callback):
|
||||||
self._callback = callback
|
self._callback = callback
|
||||||
@@ -104,16 +106,24 @@ class BrokerClient:
|
|||||||
return self._client
|
return self._client
|
||||||
|
|
||||||
if IS_MICROPYTHON:
|
if IS_MICROPYTHON:
|
||||||
|
gc.collect() # Clean Python heap before importing/allocating SSL
|
||||||
import ssl
|
import ssl
|
||||||
ssl_params = self.ssl_params
|
ssl_params = self.ssl_params
|
||||||
|
|
||||||
if self.use_tls and ssl_params is None:
|
if self.use_tls and ssl_params is None:
|
||||||
# MicroPython uses context-less structures.
|
# OPTION A: If broker uses 'require_certificate false' and self-signed certs:
|
||||||
# If your CA is self-signed, validation can fail without a valid hostname match.
|
# Do NOT pass cadata when cert_reqs is CERT_NONE to save ~20KB of C-DRAM
|
||||||
ssl_params = {
|
ssl_params = {
|
||||||
"cert_reqs": ssl.CERT_NONE, # Temporarily change to NONE to test if validation is the culprit
|
"cert_reqs": ssl.CERT_NONE,
|
||||||
"cadata": _read_file_bytes(self.cafile)
|
"server_hostname": self.host
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# OPTION B: If strict CA validation IS required, load cadata ONLY with CERT_REQUIRED:
|
||||||
|
# ssl_params = {
|
||||||
|
# "cert_reqs": ssl.CERT_REQUIRED,
|
||||||
|
# "cadata": _read_file_bytes(self.cafile),
|
||||||
|
# "server_hostname": self.host
|
||||||
|
# }
|
||||||
|
|
||||||
client = _MQTTClient(
|
client = _MQTTClient(
|
||||||
self.client_id or "smartWave-client",
|
self.client_id or "smartWave-client",
|
||||||
@@ -150,19 +160,35 @@ class BrokerClient:
|
|||||||
return self._client
|
return self._client
|
||||||
|
|
||||||
def connect(self):
|
def connect(self):
|
||||||
client = self.open()
|
|
||||||
if IS_MICROPYTHON:
|
if IS_MICROPYTHON:
|
||||||
client.connect()
|
gc.collect() # Force C & Python memory cleanup right before TLS handshake
|
||||||
return client
|
|
||||||
|
|
||||||
client.connect(self.host, self.port, self.keepalive)
|
if self._client is not None:
|
||||||
return client
|
self.close()
|
||||||
|
|
||||||
|
client = self.open()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if IS_MICROPYTHON:
|
||||||
|
gc.collect() # Sweep memory right before umqtt calls ssl.wrap_socket()
|
||||||
|
with self._lock:
|
||||||
|
client.connect()
|
||||||
|
return client
|
||||||
|
|
||||||
|
client.connect(self.host, self.port, self.keepalive)
|
||||||
|
return client
|
||||||
|
except Exception as e:
|
||||||
|
print("MQTT connection failed, closing client and releasing memory.")
|
||||||
|
print("Exception:", e)
|
||||||
|
self.close()
|
||||||
|
raise
|
||||||
|
|
||||||
def publish(self, topic, payload, qos=2, retain=False):
|
def publish(self, topic, payload, qos=2, retain=False):
|
||||||
client = self.open()
|
client = self.open()
|
||||||
payload_bytes = _ensure_bytes(payload)
|
payload_bytes = _ensure_bytes(payload)
|
||||||
if IS_MICROPYTHON:
|
if IS_MICROPYTHON:
|
||||||
return client.publish(topic, payload_bytes, retain=retain, qos=qos)
|
with self._lock:
|
||||||
|
return client.publish(topic, payload_bytes, retain=retain, qos=qos)
|
||||||
|
|
||||||
if isinstance(topic, bytes):
|
if isinstance(topic, bytes):
|
||||||
topic = topic.decode('utf-8')
|
topic = topic.decode('utf-8')
|
||||||
@@ -172,8 +198,9 @@ class BrokerClient:
|
|||||||
def subscribe(self, topic, qos=2):
|
def subscribe(self, topic, qos=2):
|
||||||
client = self.open()
|
client = self.open()
|
||||||
if IS_MICROPYTHON:
|
if IS_MICROPYTHON:
|
||||||
client.set_callback(self._on_micropython_message)
|
with self._lock:
|
||||||
return client.subscribe(topic, qos=qos)
|
client.set_callback(self._on_micropython_message)
|
||||||
|
return client.subscribe(topic, qos=qos)
|
||||||
|
|
||||||
if isinstance(topic, bytes):
|
if isinstance(topic, bytes):
|
||||||
topic = topic.decode('utf-8')
|
topic = topic.decode('utf-8')
|
||||||
@@ -184,27 +211,43 @@ class BrokerClient:
|
|||||||
client = self.open()
|
client = self.open()
|
||||||
if IS_MICROPYTHON:
|
if IS_MICROPYTHON:
|
||||||
import struct
|
import struct
|
||||||
# Ensure the topic is bytes for writing to the socket
|
import time
|
||||||
topic_bytes = topic if isinstance(topic, bytes) else topic.encode('utf-8')
|
topic_bytes = topic if isinstance(topic, bytes) else topic.encode('utf-8')
|
||||||
|
|
||||||
# 1. Build the MQTT unsubscribe packet header
|
# 1. Increment and lock the PID for THIS specific request
|
||||||
|
client.pid = (client.pid % 65535) + 1
|
||||||
|
sent_pid = client.pid # <-- Store local copy
|
||||||
|
|
||||||
|
# 2. Construct UNSUBSCRIBE packet
|
||||||
|
rem_len = 2 + 2 + len(topic_bytes)
|
||||||
pkt = bytearray(b"\xa2\0\0\0")
|
pkt = bytearray(b"\xa2\0\0\0")
|
||||||
client.pid += 1
|
struct.pack_into("!BH", pkt, 1, rem_len, sent_pid)
|
||||||
|
|
||||||
# Packet length is: 2 bytes (PID) + 2 bytes (topic length indicator) + topic string length
|
# 3. Write packet to socket
|
||||||
struct.pack_into("!BH", pkt, 1, 2 + 2 + len(topic_bytes), client.pid)
|
|
||||||
|
|
||||||
# 2. Write the packet to the socket
|
|
||||||
client.sock.write(pkt)
|
client.sock.write(pkt)
|
||||||
client._send_str(topic_bytes)
|
client._send_str(topic_bytes)
|
||||||
|
|
||||||
# 3. Wait for the UNSUBACK confirmation frame (0xB0) from the broker
|
# 4. Wait for UNSUBACK (0xB0)
|
||||||
while True:
|
start = time.time()
|
||||||
|
while time.time() - start < 3:
|
||||||
op = client.wait_msg()
|
op = client.wait_msg()
|
||||||
if op == 0xB0:
|
if op == 0xB0:
|
||||||
resp = client.sock.read(3)
|
resp = bytearray(3)
|
||||||
assert resp[1] == pkt[2] and resp[2] == pkt[3]
|
read_bytes = 0
|
||||||
|
while read_bytes < 3:
|
||||||
|
chunk = client.sock.read(3 - read_bytes)
|
||||||
|
if chunk:
|
||||||
|
resp[read_bytes:read_bytes + len(chunk)] = chunk
|
||||||
|
read_bytes += len(chunk)
|
||||||
|
else:
|
||||||
|
time.sleep_ms(10)
|
||||||
|
|
||||||
|
# Compare against sent_pid instead of client.pid
|
||||||
|
resp_pid = (resp[1] << 8) | resp[2]
|
||||||
|
if resp_pid != sent_pid:
|
||||||
|
print(f"[MQTT] UNSUBACK PID mismatch (expected {sent_pid}, got {resp_pid})")
|
||||||
return client
|
return client
|
||||||
|
|
||||||
return client
|
return client
|
||||||
|
|
||||||
if isinstance(topic, bytes):
|
if isinstance(topic, bytes):
|
||||||
@@ -219,14 +262,16 @@ class BrokerClient:
|
|||||||
if self._client is None:
|
if self._client is None:
|
||||||
return None
|
return None
|
||||||
if IS_MICROPYTHON:
|
if IS_MICROPYTHON:
|
||||||
return self._client.check_msg()
|
with self._lock:
|
||||||
|
return self._client.check_msg()
|
||||||
return self._client.loop(timeout=timeout)
|
return self._client.loop(timeout=timeout)
|
||||||
|
|
||||||
def wait(self):
|
def wait(self):
|
||||||
if self._client is None:
|
if self._client is None:
|
||||||
return None
|
return None
|
||||||
if IS_MICROPYTHON:
|
if IS_MICROPYTHON:
|
||||||
return self._client.wait_msg()
|
with self._lock:
|
||||||
|
return self._client.wait_msg()
|
||||||
return self._client.loop_forever()
|
return self._client.loop_forever()
|
||||||
|
|
||||||
def get_message(self):
|
def get_message(self):
|
||||||
@@ -235,13 +280,40 @@ class BrokerClient:
|
|||||||
return self._messages.pop(0)
|
return self._messages.pop(0)
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
|
"""Safely clean up socket context without causing ESP32 C panics."""
|
||||||
if self._client is None:
|
if self._client is None:
|
||||||
return
|
return
|
||||||
try:
|
|
||||||
self._client.disconnect()
|
if IS_MICROPYTHON:
|
||||||
except Exception:
|
with self._lock:
|
||||||
|
try:
|
||||||
|
if hasattr(self._client, "sock") and self._client.sock:
|
||||||
|
self._client.sock.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if hasattr(self._client, "sock"):
|
||||||
|
self._client.sock = None
|
||||||
|
self._client = None
|
||||||
|
gc.collect() # Immediately reclaim freed socket & mbedTLS RAM
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
self._client.disconnect()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
def ping(self):
|
||||||
|
"""Thread-safe PINGREQ wrapper for MicroPython."""
|
||||||
|
if self._client is None:
|
||||||
|
return
|
||||||
|
if IS_MICROPYTHON:
|
||||||
|
with self._lock:
|
||||||
|
return self._client.ping()
|
||||||
|
else:
|
||||||
|
# Paho handles keepalives automatically via loop_start/loop
|
||||||
pass
|
pass
|
||||||
self._client = None
|
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
self.connect()
|
self.connect()
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
from time import time
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import ujson as json
|
import ujson as json
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -20,4 +23,24 @@ def mqtt_hello_ack(id_orchestrator, id_microwave):
|
|||||||
return as_json({
|
return as_json({
|
||||||
"id_microwave": id_microwave,
|
"id_microwave": id_microwave,
|
||||||
"id_orchestrator": id_orchestrator
|
"id_orchestrator": id_orchestrator
|
||||||
|
})
|
||||||
|
|
||||||
|
def mqtt_cooking_init(id_microwave):
|
||||||
|
return as_json({
|
||||||
|
"id_microwave": id_microwave
|
||||||
|
})
|
||||||
|
|
||||||
|
def mqtt_sensor_data(id_microwave, dish_temp, ambient_temp):
|
||||||
|
return as_json({
|
||||||
|
"id_microwave": id_microwave,
|
||||||
|
"dish_temp": dish_temp,
|
||||||
|
"ambient_temp": ambient_temp
|
||||||
|
})
|
||||||
|
|
||||||
|
def mqtt_cooking_config(id_microwave, cook_time, power_level, target_temp):
|
||||||
|
return as_json({
|
||||||
|
"id_microwave": id_microwave,
|
||||||
|
"cook_time": cook_time,
|
||||||
|
"power_level": power_level,
|
||||||
|
"target_temp": target_temp
|
||||||
})
|
})
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
try:
|
||||||
|
from shared.sensors.rgb_led import RGBLED
|
||||||
|
except ImportError:
|
||||||
|
pass # No need as we are on the RPI
|
||||||
@@ -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
@@ -1,66 +1,100 @@
|
|||||||
# shared/uart_comm.py
|
|
||||||
import _thread
|
import _thread
|
||||||
from machine import UART
|
from machine import UART
|
||||||
import time
|
import time
|
||||||
|
import ujson
|
||||||
|
|
||||||
class SafeUART:
|
class SafeUART:
|
||||||
def __init__(self, uart_id, tx_pin, rx_pin, baudrate=115200):
|
def __init__(self, uart_id, tx_pin, rx_pin, baudrate=115200):
|
||||||
# Initialize the hardware UART channel
|
# Setting timeout allows readline() to be non-blocking
|
||||||
self.uart = UART(uart_id, baudrate=baudrate, tx=tx_pin, rx=rx_pin, timeout=10)
|
self.uart = UART(uart_id, baudrate=baudrate, tx=tx_pin, rx=rx_pin, timeout=10, rxbuf=1024)
|
||||||
|
|
||||||
# Core thread-safety assets
|
|
||||||
self.lock = _thread.allocate_lock()
|
self.lock = _thread.allocate_lock()
|
||||||
self.rx_queue = []
|
self.rx_queue = []
|
||||||
self.buffer = b""
|
|
||||||
|
|
||||||
# Start the background data worker thread
|
_thread.stack_size(4096)
|
||||||
_thread.stack_size(4096) # Cap the stack size for the UART listener
|
|
||||||
_thread.start_new_thread(self._listener_worker, ())
|
_thread.start_new_thread(self._listener_worker, ())
|
||||||
_thread.stack_size(0)
|
_thread.stack_size(0)
|
||||||
|
|
||||||
print(f"[UART] Thread initialized on UART{uart_id} (TX:{tx_pin}, RX:{rx_pin})")
|
|
||||||
|
|
||||||
def _listener_worker(self):
|
def _listener_worker(self):
|
||||||
"""Asynchronous internal loop parsing incoming stream lines into the queue."""
|
"""Simple worker that relies on newline framing instead of manual JSON parsing."""
|
||||||
while True:
|
while True:
|
||||||
try:
|
if self.uart.any():
|
||||||
if self.uart.any():
|
with self.lock:
|
||||||
with self.lock:
|
line = self.uart.readline()
|
||||||
# Pull all raw bytes waiting in the hardware ring buffer
|
|
||||||
chunk = self.uart.read(self.uart.any())
|
|
||||||
if chunk:
|
|
||||||
self.buffer += chunk
|
|
||||||
|
|
||||||
# Process complete lines terminated by a newline character
|
|
||||||
while b'\n' in self.buffer:
|
|
||||||
line, self.buffer = self.buffer.split(b'\n', 1)
|
|
||||||
try:
|
|
||||||
decoded_line = line.decode('utf-8').strip()
|
|
||||||
if decoded_line:
|
|
||||||
self.rx_queue.append(decoded_line)
|
|
||||||
except Exception:
|
|
||||||
pass # Discard corrupt data frames safely
|
|
||||||
except Exception as e:
|
|
||||||
print("[UART Thread Error]:", e)
|
|
||||||
|
|
||||||
time.sleep_ms(20) # Give other background threads breathing room
|
if line:
|
||||||
|
try:
|
||||||
|
decoded = line.decode('utf-8').strip()
|
||||||
|
if decoded: # Ignore empty lines
|
||||||
|
with self.lock:
|
||||||
|
self.rx_queue.append(decoded)
|
||||||
|
except UnicodeError:
|
||||||
|
pass # Drop corrupted bytes cleanly
|
||||||
|
|
||||||
|
time.sleep_ms(10)
|
||||||
|
|
||||||
def send(self, message):
|
def send(self, message):
|
||||||
"""Safely pushes strings across the serial wire from any thread context."""
|
|
||||||
if not message.endswith('\n'):
|
if not message.endswith('\n'):
|
||||||
message += '\n'
|
message += '\n'
|
||||||
|
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.uart.write(message.encode('utf-8'))
|
self.uart.write(message.encode('utf-8'))
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
with self.lock:
|
||||||
|
return self.rx_queue.pop(0) if self.rx_queue else None
|
||||||
|
|
||||||
|
def send_as_command(self, command: 'UARTCommand'):
|
||||||
|
"""Safely sends a structured command over UART."""
|
||||||
|
json_message = command.to_json()
|
||||||
|
self.send(json_message)
|
||||||
|
|
||||||
def any(self):
|
def any(self):
|
||||||
"""Checks if any complete messages are waiting to be read."""
|
"""Checks if any complete messages are waiting to be read."""
|
||||||
with self.lock:
|
with self.lock:
|
||||||
return len(self.rx_queue) > 0
|
return len(self.rx_queue) > 0
|
||||||
|
|
||||||
|
def read_as_command(self) -> 'UARTCommand | None':
|
||||||
|
"""Attempts to read the oldest unread string and parse it as a UARTCommand. Returns None if empty or invalid."""
|
||||||
|
raw_message = self.read()
|
||||||
|
if raw_message is not None:
|
||||||
|
cmd = UARTCommand.from_json(raw_message)
|
||||||
|
if cmd is None:
|
||||||
|
print("[UART] Impossible de traiter le message brut :", raw_message)
|
||||||
|
return cmd
|
||||||
|
return None
|
||||||
|
|
||||||
def read(self):
|
|
||||||
"""Pulls the oldest unread string from the queue. Returns None if empty."""
|
class UARTCommand:
|
||||||
with self.lock:
|
"""A simple wrapper for commands sent over UART, allowing for structured data."""
|
||||||
if self.rx_queue:
|
def __init__(self, command_type: str, payload):
|
||||||
return self.rx_queue.pop(0)
|
self.command_type = command_type
|
||||||
return None
|
self.payload = payload
|
||||||
|
|
||||||
|
def to_json(self):
|
||||||
|
"""Serializes the command to a JSON string."""
|
||||||
|
return ujson.dumps({
|
||||||
|
"command_type": self.command_type,
|
||||||
|
"payload": self.payload
|
||||||
|
})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_json(json_string: str) -> 'UARTCommand | None':
|
||||||
|
"""Deserializes a JSON string into a UARTCommand object."""
|
||||||
|
try:
|
||||||
|
# Remplacement préventif si des guillemets simples sont reçus
|
||||||
|
clean_str = json_string.replace("'", '"') if "'" in json_string else json_string
|
||||||
|
data = ujson.loads(clean_str)
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return UARTCommand(data.get("command_type"), data.get("payload"))
|
||||||
|
except Exception as err:
|
||||||
|
# Affiche l'erreur exacte rencontrée par ujson (ex: syntax error)
|
||||||
|
print(f"[UARTCommand Parsing Error]: {err} -> Contenu: {json_string}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class UARTCommandType:
|
||||||
|
"""Enumeration of known UART command types."""
|
||||||
|
COOKING_PARAMS = "COOKING_PARAMS"
|
||||||
|
COOKING_STATE_UPDATE = "COOKING_STATE_UPDATE"
|
||||||
@@ -4,7 +4,7 @@ Edit BROKER_HOST so it points to the broker machine IP address.
|
|||||||
Do not use localhost from the ESP32.
|
Do not use localhost from the ESP32.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from shared.mqtt import BrokerClient
|
import shared
|
||||||
|
|
||||||
|
|
||||||
BROKER_HOST = "192.168.50.1"
|
BROKER_HOST = "192.168.50.1"
|
||||||
@@ -17,7 +17,7 @@ def on_message(message):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
client = BrokerClient(
|
client = shared.get_mqtt_client(
|
||||||
host=BROKER_HOST,
|
host=BROKER_HOST,
|
||||||
client_id="smartwave-esp32-demo",
|
client_id="smartwave-esp32-demo",
|
||||||
use_tls=True,
|
use_tls=True,
|
||||||
@@ -28,7 +28,7 @@ def main():
|
|||||||
client.set_callback(on_message)
|
client.set_callback(on_message)
|
||||||
client.connect()
|
client.connect()
|
||||||
client.subscribe(TOPIC, qos=2)
|
client.subscribe(TOPIC, qos=2)
|
||||||
client.publish(TOPIC, b"hello from MicroPython", qos=2, retain=False)
|
client.publish(TOPIC, b"hello from MicroPython", qos=1, retain=False)
|
||||||
|
|
||||||
for _ in range(30):
|
for _ in range(30):
|
||||||
client.poll()
|
client.poll()
|
||||||
|
|||||||
Reference in New Issue
Block a user