Compare commits
53 Commits
0e89e42b48
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dc30f6ce6c | |||
| 16e3325f8f | |||
| 988cb7958c | |||
| 053964e56c | |||
| ec9be8ac30 | |||
| f30b29be69 | |||
| 46acbb88c2 | |||
| 6ab2a2bea3 | |||
| 35a330e7ae | |||
| 25e635e207 | |||
| fb41a2d07a | |||
| fb6ee0099a | |||
| 038171d68d | |||
| 5381631876 | |||
| 2df05fd684 | |||
| 11357b9a0e | |||
| e2beef511c | |||
| e748d53c94 | |||
| 6fa74c6b93 | |||
| af5ea45270 | |||
| 1626b392b3 | |||
| 5c60017e8d | |||
| caf81d4bbb | |||
| 43a1822547 | |||
| 9eac93c409 | |||
| 7299a50198 | |||
| 8ac5db22c1 | |||
| b12296bf0e | |||
| 9e078490dd | |||
| 059bb75555 | |||
| 181009604d | |||
| d6290efb18 | |||
| 2c66a24e9d | |||
| 0f17e9dce6 | |||
| 81de985580 | |||
| 6a42e4a772 | |||
| a0af426c78 | |||
| 5a737c931c | |||
| 181a395b4d | |||
| a17b9f9725 | |||
| c99065f6e0 | |||
| 509ed51685 | |||
| 62109c65e8 | |||
| 8a08b169fd | |||
| 7eda438d45 | |||
| 526074aef2 | |||
| 0671a37e0c | |||
| 6a08d1ef9e | |||
| 2dd664c4b4 | |||
| 1ec3ee7abf | |||
| dd808b1d68 | |||
| 4366abba69 | |||
| 987067aa1e |
@@ -1,2 +1,4 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
orchestrateur/db.sqlite-shm
|
orchestrateur/db.sqlite-shm
|
||||||
|
venv/
|
||||||
|
.env
|
||||||
Vendored
+1
@@ -9,6 +9,7 @@
|
|||||||
"${workspaceFolder}/shared",
|
"${workspaceFolder}/shared",
|
||||||
"${workspaceFolder}/micro_ondes/esp_lora/lib"
|
"${workspaceFolder}/micro_ondes/esp_lora/lib"
|
||||||
],
|
],
|
||||||
|
"python.terminal.useEnvFile": true,
|
||||||
"python.defaultInterpreterPath": "${workspaceFolder}/venv/bin/python",
|
"python.defaultInterpreterPath": "${workspaceFolder}/venv/bin/python",
|
||||||
"r.lsp.promptToInstall": false,
|
"r.lsp.promptToInstall": false,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 *
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import os
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
API_HOST = os.getenv("OPENAI_API_HOST", "https://chat.matthiasg.dev/ollama")
|
||||||
|
AI_MODEL = os.getenv("OPENAI_MODEL", "llava:7b-v1.6-mistral-q4_1")
|
||||||
|
AI_MODEL_THINK = os.getenv("OPENAI_MODEL_THINK", "True").lower() in ("true", "1", "t")
|
||||||
|
OPENAPI_TOKEN = os.getenv("OPENAI_API_TOKEN", None)
|
||||||
|
OPENAPI_ENDPOINT = "/api/generate"
|
||||||
|
|
||||||
|
print(f"Using API Host: {API_HOST}")
|
||||||
|
print(f"Using API Model: {AI_MODEL}")
|
||||||
|
print(f"Using API Model Think: {AI_MODEL_THINK}")
|
||||||
|
print(f"Using API Token: {'Yes' if OPENAPI_TOKEN else 'No'} {OPENAPI_TOKEN[:5] + '...' if OPENAPI_TOKEN else ''}")
|
||||||
|
|
||||||
|
def call_api(body: dict, endpoint: str = OPENAPI_ENDPOINT) -> str:
|
||||||
|
"""Call the API with the given endpoint and body dict."""
|
||||||
|
url = f"{API_HOST}{endpoint}"
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
if OPENAPI_TOKEN:
|
||||||
|
headers["Authorization"] = f"Bearer {OPENAPI_TOKEN}"
|
||||||
|
|
||||||
|
json_data = json.dumps(body).encode("utf-8")
|
||||||
|
req = urllib.request.Request(url, data=json_data, headers=headers, method="POST")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as response:
|
||||||
|
return response.read().decode("utf-8")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
error_body = e.read().decode("utf-8")
|
||||||
|
raise Exception(f"Error calling API: HTTP {e.code} - {error_body}")
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
raise Exception(f"Failed to reach server: {e.reason}")
|
||||||
|
|
||||||
|
|
||||||
|
def generate(
|
||||||
|
model: str = AI_MODEL,
|
||||||
|
prompt: str = "",
|
||||||
|
images: list[str] = None,
|
||||||
|
output_format: str = None,
|
||||||
|
system_message: str = None,
|
||||||
|
keep_alive: bool = True,
|
||||||
|
should_think: bool = AI_MODEL_THINK,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Generate a response for a given prompt with a provided model via the Ollama/OpenAI API.
|
||||||
|
Handles base64 encoding for local image file paths and structures the request body.
|
||||||
|
"""
|
||||||
|
if images is None:
|
||||||
|
images = []
|
||||||
|
|
||||||
|
# Transform image file paths to base64 strings
|
||||||
|
encoded_images = []
|
||||||
|
for img_path in images:
|
||||||
|
if os.path.isfile(img_path):
|
||||||
|
with open(img_path, "rb") as image_file:
|
||||||
|
encoded_images.append(base64.b64encode(image_file.read()).decode("utf-8"))
|
||||||
|
else:
|
||||||
|
# If it's already a base64 string or an invalid path, keep as-is
|
||||||
|
encoded_images.append(img_path)
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"model": model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"images": encoded_images,
|
||||||
|
"think": should_think,
|
||||||
|
"stream": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
if system_message is not None:
|
||||||
|
body["system"] = system_message
|
||||||
|
|
||||||
|
if output_format is not None:
|
||||||
|
try:
|
||||||
|
body["format"] = json.loads(output_format)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
body["format"] = output_format
|
||||||
|
|
||||||
|
if not keep_alive:
|
||||||
|
body["keep_alive"] = "0m"
|
||||||
|
|
||||||
|
response_text = call_api(body)
|
||||||
|
|
||||||
|
try:
|
||||||
|
decoded_response = json.loads(response_text)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise Exception(f"Error decoding JSON response: {e}")
|
||||||
|
|
||||||
|
return decoded_response.get("response", "")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Example usage:
|
||||||
|
result = generate(
|
||||||
|
prompt="Explain what you see in the image or answer this prompt.",
|
||||||
|
should_think=AI_MODEL_THINK,
|
||||||
|
)
|
||||||
|
print(result)
|
||||||
File diff suppressed because it is too large
Load Diff
+10
-8
@@ -1,22 +1,24 @@
|
|||||||
# Use a lightweight Python 3.11 image
|
|
||||||
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
|
||||||
|
|
||||||
# Copy the requirements file and install dependencies
|
# Copy requirements from build context root or relative path
|
||||||
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", "app:app"]
|
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--threads", "4", "--timeout", "300", "app:app"]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
in `/cloud` folder :
|
||||||
|
|
||||||
|
`flask run --debug`
|
||||||
+61
-25
@@ -3,9 +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 APIs import generate, EdamamAPI
|
||||||
# Import your shared device types
|
import sys
|
||||||
from shared import deviceTypes
|
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__)
|
||||||
|
|
||||||
@@ -20,9 +25,14 @@ db = client["microwave_network_db"]
|
|||||||
cooking_collection = db["cooking_parameters"]
|
cooking_collection = db["cooking_parameters"]
|
||||||
device_network_collection = db["device_network"]
|
device_network_collection = db["device_network"]
|
||||||
|
|
||||||
# Ensure the photo storage directory exists when the app starts
|
# Ensure the camera image storage directory exists when the app starts
|
||||||
PHOTO_DIR = "storage/dishPhotos"
|
CAMERA_IMAGE_DIR = "storage/dishCameraImages"
|
||||||
os.makedirs(PHOTO_DIR, exist_ok=True)
|
os.makedirs(CAMERA_IMAGE_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# Classes
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
microwave_cook_planner = MicrowaveCookPlanner()
|
||||||
|
|
||||||
# ---------------------------------------------------------
|
# ---------------------------------------------------------
|
||||||
# Routes
|
# Routes
|
||||||
@@ -30,7 +40,9 @@ os.makedirs(PHOTO_DIR, exist_ok=True)
|
|||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def hello_world():
|
def hello_world():
|
||||||
return "<p>Hello, World!</p>"
|
gen = generate(prompt="Say Hello, to the user !")
|
||||||
|
print(gen)
|
||||||
|
return f"<p>{gen}</p>"
|
||||||
|
|
||||||
|
|
||||||
@app.route("/cooking-params", methods=["POST"])
|
@app.route("/cooking-params", methods=["POST"])
|
||||||
@@ -40,37 +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
|
||||||
|
|
||||||
# 1. Handle the Photo
|
# Extract user or device parameters (with fallback defaults)
|
||||||
photo_b64 = data.get("photo")
|
height_cm = float(data.get("dish_height", 4.0))
|
||||||
if photo_b64:
|
initial_temp_c = float(data.get("ir_initial_temp", 20.0)) # e.g., 4.0 for fridge, -18.0 for freezer
|
||||||
# Generate a unique filename using UUID to avoid overwriting
|
microwave_wattage = int(data.get("microwave_wattage", 900)) # e.g., 900W
|
||||||
|
defrost_mode = bool(data.get("defrost_mode", False)) # True for defrost, False for cook/reheat
|
||||||
|
print("Parsed parameters - Height (cm):", height_cm, "Initial Temp (C):", initial_temp_c, "Microwave Wattage:", microwave_wattage, "Defrost Mode:", defrost_mode)
|
||||||
|
|
||||||
|
# 1. Handle the Camera Image
|
||||||
|
camera_image_b64 = data.get("camera_image")
|
||||||
|
filepath = None
|
||||||
|
|
||||||
|
if camera_image_b64:
|
||||||
filename = f"dish_{uuid.uuid4().hex}.jpg"
|
filename = f"dish_{uuid.uuid4().hex}.jpg"
|
||||||
filepath = os.path.join(PHOTO_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(photo_b64))
|
f.write(base64.b64decode(camera_image_b64))
|
||||||
|
|
||||||
# Replace the giant base64 string in the dictionary with the local file path
|
data["camera_image"] = filepath
|
||||||
# so we don't bloat the MongoDB document
|
|
||||||
data["photo"] = filepath
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": f"Failed to save photo: {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
|
||||||
|
|
||||||
|
# 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():
|
||||||
@@ -90,6 +121,11 @@ 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
|
||||||
@@ -14,7 +14,7 @@ RPI_SYSTEMD_SERVICE="smartwave.service"
|
|||||||
|
|
||||||
# Vérification des arguments
|
# Vérification des arguments
|
||||||
if [ -z "$1" ]; then
|
if [ -z "$1" ]; then
|
||||||
echo "Usage: ./deploy.sh [wifi|lora|rpi|all]"
|
echo "Usage: ./deploy.sh [wifi|mqtt|lora|rpi|all]"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -79,6 +79,9 @@ case $CIBLE in
|
|||||||
"wifi")
|
"wifi")
|
||||||
deploy_to_esp "micro_ondes/esp_wifi" "$PORT_ESP_WIFI" "ESP-WIFI"
|
deploy_to_esp "micro_ondes/esp_wifi" "$PORT_ESP_WIFI" "ESP-WIFI"
|
||||||
;;
|
;;
|
||||||
|
"mqtt")
|
||||||
|
deploy_to_esp "micro_ondes/esp_wifi" "$PORT_ESP_WIFI" "ESP-WIFI"
|
||||||
|
;;
|
||||||
"lora")
|
"lora")
|
||||||
deploy_to_esp "micro_ondes/esp_lora" "$PORT_ESP_LORA" "ESP-LORA"
|
deploy_to_esp "micro_ondes/esp_lora" "$PORT_ESP_LORA" "ESP-LORA"
|
||||||
;;
|
;;
|
||||||
@@ -92,6 +95,6 @@ case $CIBLE in
|
|||||||
# Ajoute les autres ici
|
# Ajoute les autres ici
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Cible inconnue. Utilise 'wifi', 'lora' ou 'all'."
|
echo "Cible inconnue. Utilise 'wifi', 'lora', 'mqtt' ou 'all'."
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
+12
-1
@@ -1,5 +1,16 @@
|
|||||||
|
|
||||||
# LoRa
|
# LoRa
|
||||||
|
|
||||||
`mpremote connect /dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0 repl`
|
`mpremote connect /dev/serial/by-path/pci-0000:00:14.0-usb-0:6.1:1.0-port0 repl`
|
||||||
|
|
||||||
|
# MQTT
|
||||||
|
|
||||||
|
`mpremote connect /dev/serial/by-path/pci-0000:00:14.0-usb-0:6.2:1.0-port0 repl`
|
||||||
|
|
||||||
|
# UART
|
||||||
|
|
||||||
|
| LoRa | MQTT |
|
||||||
|
| --- | --- |
|
||||||
|
| 45 | P17 |
|
||||||
|
| 46 | P16 |
|
||||||
|
| GND | GND |
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import time
|
||||||
|
from shared.cookingState import CookingState, CookingStates
|
||||||
|
from shared import config
|
||||||
|
import framebuf
|
||||||
|
|
||||||
|
# Icon and bitmaps
|
||||||
|
image_weather_frost_bits = bytearray(b'\x01\x00\x13\x901\x18s\x9c\x09 \x05@S\x94\xfe\xfeS\x94\x05@\x09 s\x9c1\x18\x13\x90\x01\x00\x00\x00')
|
||||||
|
image_Alert_bits = bytearray(b'\xf7\x80\xe3\x80\xeb\x80\xc9\x80\xc9\x80\x80\x80\x88\x80\x00\x00')
|
||||||
|
image_CoolHiSmall_bits = bytearray(b'\x1f\xff\xc0?\xff\xe0\x7f\xff\xf0\xff\xdf\xf8\xffW\xf8\xfd\x8d\xf8\xf9\xdc\xf8\xfe\xdb\xf8\xf7Wx\xfb\x8e\xf8\xe0\x008\xfb\x8e\xf8\xf7Wx\xfe\xdb\xf8\xf9\xdc\xf8\xfd\x8d\xf8\xffW\xf8\xff\xdf\xf8\x7f\xff\xf0?\xff\xe0\x1f\xff\xc0')
|
||||||
|
image_CoolLoSmall_bits = bytearray(b'\x1f\xff\xc0 \x00 @\x00\x10\x80 \x08\x80\xa8\x08\x82r\x08\x86#\x08\x81$\x08\x88\xa8\x88\x84q\x08\x9f\xff\xc8\x84q\x08\x88\xa8\x88\x81$\x08\x86#\x08\x82r\x08\x80\xa8\x08\x80 \x08@\x00\x10 \x00 \x1f\xff\xc0')
|
||||||
|
image_music_sound_wave_bits = bytearray(b'\x00\x00\x00\x02\x00\x00\x02 \x00\x02 \x00\x02 \x00\x0a(\x00\x0a\xa8\x00*\xa8\x00\xaa\xaa\x80\x0a\xaa\x00\x0a\xa8\x00\x0a \x00\x02 \x00\x02 \x00\x02\x00\x00\x00\x00\x00')
|
||||||
|
image_weather_temperature_bits = bytearray(b'\x1c\x00\x22\x00+\x00*\x00+\x00*\x00+\x00*\x00*\x00I\x00\x9c\x80\xae\x80\xbe\x80\x9c\x80A\x00>\x00')
|
||||||
|
fb_image_weather_frost_bits = framebuf.FrameBuffer(image_weather_frost_bits, 15, 16, framebuf.MONO_HLSB)
|
||||||
|
fb_image_CoolHiSmall_bits = framebuf.FrameBuffer(image_CoolHiSmall_bits, 21, 21, framebuf.MONO_HLSB)
|
||||||
|
fb_image_weather_temperature_bits = framebuf.FrameBuffer(image_weather_temperature_bits, 9, 16, framebuf.MONO_HLSB)
|
||||||
|
fb_image_Alert_bits = framebuf.FrameBuffer(image_Alert_bits, 9, 8, framebuf.MONO_HLSB)
|
||||||
|
fb_image_music_sound_wave_bits = framebuf.FrameBuffer(image_music_sound_wave_bits, 17, 16, framebuf.MONO_HLSB)
|
||||||
|
fb_image_CoolLoSmall_bits = framebuf.FrameBuffer(image_CoolLoSmall_bits, 21, 21, framebuf.MONO_HLSB)
|
||||||
|
|
||||||
|
# Constants
|
||||||
|
CHARACTER_WIDTH = 6
|
||||||
|
CHARACTER_GAP = 2
|
||||||
|
CHARACTER_FULL_WIDTH = CHARACTER_WIDTH + CHARACTER_GAP
|
||||||
|
CHARACTER_HEIGHT = 7
|
||||||
|
|
||||||
|
|
||||||
|
class MicrowaveScreen:
|
||||||
|
# Used for animation
|
||||||
|
# lastUpdateTime = 0 # Not used right now
|
||||||
|
|
||||||
|
def __init__(self, display):
|
||||||
|
self.display = display
|
||||||
|
self.width = display.width
|
||||||
|
self.height = display.height
|
||||||
|
|
||||||
|
def h_centered_text(self, text, y, color=1):
|
||||||
|
"""Centers text horizontally
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text (str): _description_
|
||||||
|
y (int): _description_
|
||||||
|
color (int, optional): _description_. Defaults to 1.
|
||||||
|
"""
|
||||||
|
text = str(text)
|
||||||
|
text_width = len(text) * CHARACTER_FULL_WIDTH
|
||||||
|
|
||||||
|
if config.DEBUG:
|
||||||
|
if text_width > self.width:
|
||||||
|
print(f"[MicrowaveScreen] Warning: Text '{text}' is too long to fit on the screen.")
|
||||||
|
|
||||||
|
x = (self.width - text_width) // 2
|
||||||
|
self.display.text(text, x, y, color)
|
||||||
|
|
||||||
|
def flick(self):
|
||||||
|
self.display.fill(0)
|
||||||
|
|
||||||
|
def show(self):
|
||||||
|
self.display.show()
|
||||||
|
|
||||||
|
def bootScreen(self):
|
||||||
|
self.display.text("SmartWave", 28, 28, 1)
|
||||||
|
self.show()
|
||||||
|
|
||||||
|
def _progressBar(self, progress, x, y, width=87, height=13, color=1):
|
||||||
|
# Box outline
|
||||||
|
self.display.rect(x, y, width, height, color)
|
||||||
|
|
||||||
|
# Fill the progress bar based on the progress value (0.0 to 1.0)
|
||||||
|
fill_width = int(progress * (width)) # Subtract 2
|
||||||
|
|
||||||
|
self.display.fill_rect(x, y, fill_width, height, color)
|
||||||
|
|
||||||
|
def update(self, cooking_state: CookingState | None, defrost_mode: bool = False):
|
||||||
|
self.flick()
|
||||||
|
|
||||||
|
# self.lastUpdateTime = time.time() # Not Used right now
|
||||||
|
|
||||||
|
if not cooking_state or cooking_state.state == CookingStates.IDLE: # Is not currently cooking
|
||||||
|
self.h_centered_text("Ready !", 28, 1)
|
||||||
|
else:
|
||||||
|
# Screen center
|
||||||
|
if cooking_state.state == CookingStates.STIRRING_REQUIRED:
|
||||||
|
self._message("Pls Stir", True)
|
||||||
|
elif cooking_state.state == CookingStates.ALERT:
|
||||||
|
self._message("Alert !", True)
|
||||||
|
elif cooking_state.state == CookingStates.DONE:
|
||||||
|
self._message("Done !", False)
|
||||||
|
else: # Progress bar
|
||||||
|
elapsed_time = cooking_state.get_elapsed_time()
|
||||||
|
estimated_progress = elapsed_time / (elapsed_time + cooking_state.estimated_remaining_time)
|
||||||
|
self._progressBar(estimated_progress, 21, 25)
|
||||||
|
|
||||||
|
# Estimated remaining time
|
||||||
|
if cooking_state.paused:
|
||||||
|
self.h_centered_text("Paused", 40, 1)
|
||||||
|
elif cooking_state.state != CookingStates.DONE:
|
||||||
|
remaining_time = int(cooking_state.estimated_remaining_time)
|
||||||
|
minutes = remaining_time // 60
|
||||||
|
seconds = remaining_time % 60
|
||||||
|
self.h_centered_text(f"{minutes:02}:{seconds:02}", 40, 1)
|
||||||
|
|
||||||
|
# Current dish temperature
|
||||||
|
if cooking_state.current_dish_temp is not None and cooking_state.current_dish_temp is not 0.0:
|
||||||
|
self.display.blit(fb_image_weather_temperature_bits, 27, 2)
|
||||||
|
self.display.text(str(int(cooking_state.current_dish_temp)), 37, 6, 1)
|
||||||
|
self.display.ellipse(54, 6, 1, 1, 1, False)
|
||||||
|
self.display.text("C", 56, 6, 1)
|
||||||
|
|
||||||
|
# Power level
|
||||||
|
if cooking_state.power_level is not None:
|
||||||
|
self.display.blit(fb_image_music_sound_wave_bits, 74, 2)
|
||||||
|
self.display.text(f"{cooking_state.power_level}W", 92, 6, 1)
|
||||||
|
if defrost_mode:
|
||||||
|
self.display.blit(fb_image_CoolHiSmall_bits, 2, 2)
|
||||||
|
else:
|
||||||
|
self.display.blit(fb_image_CoolLoSmall_bits, 2, 2)
|
||||||
|
|
||||||
|
self.show()
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _message(self, text, alert):
|
||||||
|
self.display.fill_rect(0, 27, self.width, CHARACTER_HEIGHT + 4, 1)
|
||||||
|
if alert:
|
||||||
|
self.display.blit(fb_image_Alert_bits, 10, 29)
|
||||||
|
self.display.blit(fb_image_Alert_bits, self.width - 10, 29)
|
||||||
|
self.h_centered_text(text, 29, 0)
|
||||||
|
|
||||||
|
def message(self, text, alert):
|
||||||
|
self.flick()
|
||||||
|
self._message(text, alert)
|
||||||
|
self.show()
|
||||||
|
pass
|
||||||
+295
-40
@@ -1,57 +1,312 @@
|
|||||||
import _thread
|
import gc
|
||||||
from machine import Pin
|
import sys
|
||||||
from shared import get_lora
|
|
||||||
from shared import deviceTypes
|
|
||||||
from shared import config
|
|
||||||
import time
|
import time
|
||||||
|
import _thread
|
||||||
|
from lib.microwaveScreen import MicrowaveScreen
|
||||||
|
import uasyncio as asyncio
|
||||||
|
from machine import Pin, SoftI2C
|
||||||
|
import ssd1306
|
||||||
|
import ujson
|
||||||
|
|
||||||
# --- Configuration Matérielle ---
|
# Clean memory immediately
|
||||||
vext = Pin(19, Pin.OUT)
|
gc.collect()
|
||||||
vext.value(0)
|
|
||||||
time.sleep_ms(100)
|
|
||||||
|
|
||||||
# --- Lecture de l'ID unique de l'ESP ---
|
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
|
||||||
|
|
||||||
|
# --- READ DEVICE ID ---
|
||||||
try:
|
try:
|
||||||
with open("device_id.txt", "r") as f:
|
with open("device_id.txt", "r") as f:
|
||||||
DEVICE_ID = f.read().strip()
|
DEVICE_ID = f.read().strip()
|
||||||
except Exception:
|
except Exception:
|
||||||
DEVICE_ID = "ESP32_Inconnu"
|
DEVICE_ID = "ESP32_Inconnu"
|
||||||
|
|
||||||
# --- Initialisation LoRa ---
|
# --- GLOBAL VARIABLES ---
|
||||||
lora = get_lora()
|
cooking_state = None
|
||||||
lora.configure(freq=868.1, sf=7)
|
data_queue = SafeQueue()
|
||||||
|
lora = None
|
||||||
|
uart_device = None
|
||||||
|
magnetron_led = None
|
||||||
|
microwave_screen = None
|
||||||
|
defrost_mode = False
|
||||||
|
current_temp = [None, None]
|
||||||
|
last_temp = [None, None]
|
||||||
|
temperature_asked = False
|
||||||
|
last_temp_request_time = 0
|
||||||
|
|
||||||
print(f"ESP32 initialisé avec l'ID : '{DEVICE_ID}' (Type : {deviceTypes.DEVICE_TYPES['MICROWAVE']})")
|
|
||||||
|
|
||||||
def heartbeat_loop():
|
PING_PAYLOAD = {
|
||||||
|
"id": DEVICE_ID,
|
||||||
|
"type": deviceTypes.DEVICE_TYPES["MICROWAVE"]
|
||||||
|
}
|
||||||
|
|
||||||
|
def init_hardware():
|
||||||
|
"""Initializes all hardware components."""
|
||||||
|
global lora, uart_device, magnetron_led, microwave_screen
|
||||||
|
|
||||||
|
print("[Main] Initializing hardware peripherals...")
|
||||||
|
|
||||||
|
# Power up VEXT (for LoRa/Display)
|
||||||
|
vext = Pin(19, Pin.OUT)
|
||||||
|
vext.value(0)
|
||||||
|
time.sleep_ms(100)
|
||||||
|
|
||||||
|
# Init OLED Display
|
||||||
|
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)
|
||||||
|
microwave_screen = MicrowaveScreen(display)
|
||||||
|
microwave_screen.bootScreen()
|
||||||
|
|
||||||
|
# Init LoRa
|
||||||
|
lora = get_lora()
|
||||||
|
lora.configure(freq=868.1, sf=7)
|
||||||
|
|
||||||
|
# Init RGB LEDs
|
||||||
|
magnetron_led = RGBLED(red_pin=48, green_pin=47, blue_pin=33)
|
||||||
|
magnetron_led.color = RGBLED.WHITE_YELLOW
|
||||||
|
magnetron_led.off()
|
||||||
|
|
||||||
|
# Init UART
|
||||||
|
uart_device = get_uart(uart_id=1, tx_pin=46, rx_pin=45)
|
||||||
|
|
||||||
|
print(f"[Main] ESP32 initialized with ID: '{DEVICE_ID}' (Type: {deviceTypes.DEVICE_TYPES['MICROWAVE']})")
|
||||||
|
|
||||||
|
|
||||||
|
# --- DEDICATED LORA HARDWARE THREAD ---
|
||||||
|
def lora_hardware_thread():
|
||||||
|
"""Runs in a separate OS thread to keep the LoRa radio in continuous RX mode."""
|
||||||
|
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
|
||||||
|
log("\n[LoRa Thread] Sending Heartbeat...")
|
||||||
|
if lora:
|
||||||
|
lora.send(PING_PAYLOAD)
|
||||||
|
|
||||||
if paquet and not paquet["raw"]:
|
# 2. Blocking 300ms RX listen window (keeps radio actively listening)
|
||||||
donnees = paquet["data"]
|
if lora:
|
||||||
# Vérification si le paquet reçu est bien la réponse attendue de l'orchestrateur
|
paquet = lora.receive_reliable(timeout_ms=300)
|
||||||
if donnees.get("type") == deviceTypes.DEVICE_TYPES["ORCHESTRATOR"]:
|
if paquet is not None:
|
||||||
print(f"ESP32 : Réponse reçue de l'orchestrateur '{donnees.get('id')}' ! [Statut: ALIVE]")
|
log(f"[LoRa Thread] New Packet Received: {paquet}")
|
||||||
else:
|
data_queue.put(paquet)
|
||||||
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)
|
time.sleep_ms(10)
|
||||||
|
|
||||||
# Lancer la boucle de heartbeat dans un thread séparé
|
|
||||||
_thread.start_new_thread(heartbeat_loop, ())
|
|
||||||
|
|
||||||
while True:
|
# --- COOKING STATE CALLBACKS ---
|
||||||
# Fait rien pour l'instant
|
def cooking_state_temperature_provider():
|
||||||
time.sleep(1)
|
global current_temp, last_temp, temperature_asked, last_temp_request_time, uart_device
|
||||||
|
|
||||||
|
def temp_is_invalid(temp):
|
||||||
|
return (
|
||||||
|
temp is None
|
||||||
|
or not isinstance(temp, (list, tuple))
|
||||||
|
or len(temp) < 2
|
||||||
|
or temp[0] is None
|
||||||
|
or temp[1] is None
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. Check for UART request timeout (reset lock if 3 seconds pass without a response)
|
||||||
|
now = time.time()
|
||||||
|
if temperature_asked and (now - last_temp_request_time > 3):
|
||||||
|
print("[CookingState] Temperature request timed out. Retrying UART request...")
|
||||||
|
temperature_asked = False
|
||||||
|
|
||||||
|
# 2. Trigger new UART request if idle
|
||||||
|
if not temperature_asked and uart_device:
|
||||||
|
temperature_asked = True
|
||||||
|
last_temp_request_time = now
|
||||||
|
uart_device.send_as_command(UARTCommand(UARTCommandType.TEMPERATURE_REQUEST, {}))
|
||||||
|
|
||||||
|
# 3. Handle fresh incoming reading
|
||||||
|
if not temp_is_invalid(current_temp):
|
||||||
|
temps = [float(current_temp[0]), float(current_temp[1])]
|
||||||
|
last_temp = [temps[0], temps[1]] # Keep a safe reference copy
|
||||||
|
|
||||||
|
# Reset current_temp buffer to consume the value
|
||||||
|
current_temp = [None, None]
|
||||||
|
return temps
|
||||||
|
|
||||||
|
# 4. Fallback: Use last valid reading
|
||||||
|
if not temp_is_invalid(last_temp):
|
||||||
|
return [float(last_temp[0]), float(last_temp[1])]
|
||||||
|
|
||||||
|
# 5. Default fallback if no data has ever arrived
|
||||||
|
return (0.0, 0.0)
|
||||||
|
|
||||||
|
def cooking_state_on_state_change(state):
|
||||||
|
print(f"[CookingState] State changed to: {state.state}")
|
||||||
|
|
||||||
|
if state.paused or state.state == cookingState.CookingStates.DONE or state.state == cookingState.CookingStates.IDLE:
|
||||||
|
magnetron_led.off()
|
||||||
|
else:
|
||||||
|
magnetron_led.on()
|
||||||
|
|
||||||
|
# Send state updates to WiFi board and Orchestrator
|
||||||
|
if uart_device:
|
||||||
|
uart_device.send_as_command(UARTCommand(UARTCommandType.COOKING_STATE_UPDATE, {"state": state.state}))
|
||||||
|
if lora:
|
||||||
|
lora.send_reliable({"id": DEVICE_ID, "new_cooking_state": state.state})
|
||||||
|
|
||||||
|
microwave_screen.update(cooking_state, defrost_mode)
|
||||||
|
|
||||||
|
def cooking_state_on_refresh(state):
|
||||||
|
# Update OLED display
|
||||||
|
if microwave_screen:
|
||||||
|
microwave_screen.update(state, defrost_mode)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# --- ASYNC TASKS ---
|
||||||
|
|
||||||
|
async def uart_polling_task():
|
||||||
|
"""Polls UART for incoming messages from the WiFi board."""
|
||||||
|
global cooking_state, temperature_asked
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if uart_device and uart_device.any():
|
||||||
|
command = uart_device.read_as_command()
|
||||||
|
if command:
|
||||||
|
log(f"[UART Task] Received command from WiFi Board: {command.command_type}")
|
||||||
|
if command.command_type == UARTCommandType.COOKING_PARAMS:
|
||||||
|
params = command.payload
|
||||||
|
print(f"[UART Task] Cooking parameters received: {params}")
|
||||||
|
|
||||||
|
uart_device.send_as_command(UARTCommand(UARTCommandType.TEMPERATURE_REQUEST, {}))
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
await asyncio.sleep_ms(200)
|
||||||
|
cooking_state_on_state_change(cooking_state)
|
||||||
|
elif command.command_type == UARTCommandType.TEMPERATURE_RESPONSE:
|
||||||
|
try:
|
||||||
|
# Check if payload is already a dict or needs JSON decoding
|
||||||
|
payload = ujson.loads(command.payload) if isinstance(command.payload, str) else command.payload
|
||||||
|
current_temp[0] = payload.get("dish_temp", 0.0)
|
||||||
|
current_temp[1] = payload.get("ambient_temp", 0.0)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[UART Task] Error parsing temperature payload: {e}")
|
||||||
|
finally:
|
||||||
|
temperature_asked = False
|
||||||
|
else:
|
||||||
|
print(f"[UART Task] Unknown command type received: {command.command_type}")
|
||||||
|
|
||||||
|
await asyncio.sleep_ms(50)
|
||||||
|
|
||||||
|
|
||||||
|
async def lora_process_task():
|
||||||
|
"""Consumes packets pushed to data_queue by the LoRa hardware thread."""
|
||||||
|
global cooking_state, defrost_mode, microwave_screen
|
||||||
|
|
||||||
|
while True:
|
||||||
|
while not data_queue.empty():
|
||||||
|
paquet = data_queue.get()
|
||||||
|
if paquet and not paquet.get("raw"):
|
||||||
|
data = paquet.get("data", {})
|
||||||
|
|
||||||
|
if "action" in data:
|
||||||
|
if data["action"] == LoraCommands.TOGGLE_PAUSE:
|
||||||
|
if cooking_state is not None:
|
||||||
|
if cooking_state.state == cookingState.CookingStates.DONE:
|
||||||
|
print("[LoRa Process] Cooking is done. Resetting microwave for the next session.")
|
||||||
|
cooking_state.set_state(cookingState.CookingStates.IDLE)
|
||||||
|
await asyncio.sleep_ms(20)
|
||||||
|
cooking_state = None
|
||||||
|
else:
|
||||||
|
cooking_state.toggle_pause()
|
||||||
|
if cooking_state.paused:
|
||||||
|
print("[LoRa Process] Cooking paused via orchestrator command.")
|
||||||
|
else:
|
||||||
|
print("[LoRa Process] Cooking resumed via orchestrator command.")
|
||||||
|
else:
|
||||||
|
log("[LoRa Process] No active cooking state to toggle pause/resume.")
|
||||||
|
elif data["action"] == LoraCommands.TOGGLE_DEFROST:
|
||||||
|
print("[LoRa Process] Toggling defrost mode via orchestrator command.")
|
||||||
|
defrost_mode = data["defrost_state"]
|
||||||
|
microwave_screen.update(cooking_state, defrost_mode)
|
||||||
|
|
||||||
|
await asyncio.sleep_ms(50)
|
||||||
|
|
||||||
|
|
||||||
|
async def cooking_loop_task():
|
||||||
|
"""Ticks the cooking state and logs information periodically without flooding output."""
|
||||||
|
log_counter = 0
|
||||||
|
while True:
|
||||||
|
if cooking_state is not None:
|
||||||
|
cooking_state.update_tick()
|
||||||
|
log_counter += 1
|
||||||
|
|
||||||
|
# Print log output every 5 seconds (10 ticks x 500ms)
|
||||||
|
if log_counter % 10 == 0:
|
||||||
|
print(f"[Cooking Task] State: {cooking_state.state}, Temp: {cooking_state.current_dish_temp}, "
|
||||||
|
f"Paused: {cooking_state.paused}, Remaining: {cooking_state.get_remaining_time():.2f}s")
|
||||||
|
|
||||||
|
await asyncio.sleep_ms(500)
|
||||||
|
|
||||||
|
|
||||||
|
async def memory_cleanup_task():
|
||||||
|
"""Periodically cleans up memory to prevent heap fragmentation."""
|
||||||
|
while True:
|
||||||
|
gc.collect()
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
|
||||||
|
|
||||||
|
# --- BOOTSTRAP ---
|
||||||
|
async def main():
|
||||||
|
global cooking_state, defrost_mode, microwave_screen
|
||||||
|
print("[Main] Starting application...")
|
||||||
|
|
||||||
|
init_hardware()
|
||||||
|
|
||||||
|
# Launch dedicated hardware thread for LoRa RX
|
||||||
|
try:
|
||||||
|
_thread.stack_size(16 * 1024)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_thread.start_new_thread(lora_hardware_thread, ())
|
||||||
|
print("[Main] LoRa hardware background thread started.")
|
||||||
|
|
||||||
|
# Launch background async tasks
|
||||||
|
asyncio.create_task(uart_polling_task())
|
||||||
|
asyncio.create_task(lora_process_task())
|
||||||
|
asyncio.create_task(cooking_loop_task())
|
||||||
|
asyncio.create_task(memory_cleanup_task())
|
||||||
|
|
||||||
|
print("[Main] All async tasks running concurrently!")
|
||||||
|
|
||||||
|
microwave_screen.update(None, defrost_mode)
|
||||||
|
|
||||||
|
# Keep main task alive indefinitely
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(3600)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
asyncio.run(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("[Main] Program stopped by user.")
|
||||||
|
except Exception as e:
|
||||||
|
sys.print_exception(e)
|
||||||
@@ -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
|
||||||
#esp.osdebug(None)
|
from machine import Pin
|
||||||
|
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)
|
||||||
+315
-58
@@ -1,86 +1,343 @@
|
|||||||
import _thread
|
import gc
|
||||||
import select # <--- Built-in module to handle TLS timeouts
|
import sys
|
||||||
from machine import Pin
|
|
||||||
from shared import get_mqtt_client
|
|
||||||
from shared import config
|
|
||||||
import time
|
import time
|
||||||
|
import ujson as json
|
||||||
|
import uasyncio as asyncio
|
||||||
|
from machine import Pin, I2C
|
||||||
|
|
||||||
# --- Hardware Configuration ---
|
# 1. Clean memory immediately before performing any operations
|
||||||
vext = Pin(19, Pin.OUT)
|
gc.collect()
|
||||||
vext.value(0)
|
|
||||||
time.sleep_ms(100)
|
|
||||||
|
|
||||||
# --- Read Unique Device ID ---
|
# --- READ DEVICE ID ---
|
||||||
try:
|
try:
|
||||||
with open("device_id.txt", "r") as f:
|
with open("device_id.txt", "r") as f:
|
||||||
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 ---
|
||||||
|
from shared import get_mqtt_client, config, payloads
|
||||||
|
|
||||||
MQTT_CA_FILE = "/certs/ca.crt"
|
MQTT_CA_FILE = "/certs/ca.crt"
|
||||||
|
|
||||||
# --- Setup MQTT Client ---
|
mqtt_client = None # Will be initialized in connect_mqtt_async()
|
||||||
mqtt_client = get_mqtt_client(
|
|
||||||
host=config.MQTT_BROKER_HOST,
|
# --- HARDWARE & MODULE DEFERRED IMPORTS ---
|
||||||
client_id="smartwave-esp32-" + DEVICE_ID,
|
status_led = None
|
||||||
use_tls=config.USE_TLS,
|
uart_device = None
|
||||||
cafile=MQTT_CA_FILE,
|
mlx_temperature_sensor = None
|
||||||
keepalive=config.MQTT_KEEPALIVE, # Can safely be 30 now
|
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_cooking_state_change(state):
|
||||||
|
"""Callback executed whenever local cooking state transitions."""
|
||||||
|
BLINK_INTERVAL_MS = 500
|
||||||
|
|
||||||
|
log(f"[CookingState] State changed to: {state.state}")
|
||||||
|
|
||||||
|
if status_led and cookingState:
|
||||||
|
if state.state == cookingState.CookingStates.IDLE:
|
||||||
|
log(f"[CookingState] State changed to IDLE. Turning LED off and stopping blink.")
|
||||||
|
status_led.color = status_led.OFF
|
||||||
|
status_led.blink_off()
|
||||||
|
elif state.state == cookingState.CookingStates.COOKING:
|
||||||
|
log(f"[CookingState] State changed to COOKING. Turning LED yellow and stopping blink.")
|
||||||
|
status_led.color = status_led.YELLOW
|
||||||
|
status_led.blink_off()
|
||||||
|
elif state.state == cookingState.CookingStates.STIRRING_REQUIRED:
|
||||||
|
log(f"[CookingState] State changed to STIRRING_REQUIRED. Turning LED orange and starting blink.")
|
||||||
|
status_led.color = status_led.ORANGE
|
||||||
|
status_led.blink_on(BLINK_INTERVAL_MS)
|
||||||
|
elif state.state == cookingState.CookingStates.ALERT:
|
||||||
|
log(f"[CookingState] State changed to ALERT. Turning LED red and starting blink.")
|
||||||
|
status_led.color = status_led.RED
|
||||||
|
status_led.blink_on(BLINK_INTERVAL_MS)
|
||||||
|
elif state.state == cookingState.CookingStates.DONE:
|
||||||
|
log(f"[CookingState] State changed to DONE. Turning LED green and stopping blink.")
|
||||||
|
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
|
||||||
|
print("[MQTT] Received message on topic:", message.get("topic"))
|
||||||
|
|
||||||
mqtt_client.set_callback(on_mqtt_message)
|
payload_data = None
|
||||||
|
try:
|
||||||
|
payload_data = json.loads(message["payload"])
|
||||||
|
except Exception as e:
|
||||||
|
print("[MQTT] Payload parsing warning:", e)
|
||||||
|
|
||||||
|
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 using select.poll() for keepalive tracking."""
|
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."""
|
||||||
|
global cooking_state
|
||||||
|
while True:
|
||||||
|
if uart_device:
|
||||||
|
try:
|
||||||
|
cmd = uart_device.read_as_command()
|
||||||
|
|
||||||
|
if cmd and hasattr(cmd, "command_type"):
|
||||||
|
print("[UART] Command received from LoRa board:", cmd.command_type, cmd.payload)
|
||||||
|
if cmd.command_type == UARTCommandType.COOKING_STATE_UPDATE:
|
||||||
|
if cooking_state:
|
||||||
|
cooking_state.set_state(cmd.payload.get("state"))
|
||||||
|
elif cmd.command_type == UARTCommandType.TEMPERATURE_REQUEST:
|
||||||
|
uart_device.send_as_command(UARTCommand(UARTCommandType.TEMPERATURE_RESPONSE, payloads.lora_sensor_data(
|
||||||
|
mlx_temperature_sensor.read_object_temp() if mlx_temperature_sensor else 0,
|
||||||
|
mlx_temperature_sensor.read_ambient_temp() if mlx_temperature_sensor else 0
|
||||||
|
)))
|
||||||
|
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, 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!")
|
||||||
# --- THE SELECT POLLER SETUP ---
|
mqtt_connected = True
|
||||||
# Create a poller and register our active TLS socket to look for incoming data (POLLIN)
|
return
|
||||||
poller = select.poll()
|
|
||||||
poller.register(mqtt_client._client.sock, select.POLLIN)
|
|
||||||
|
|
||||||
# Listening loop
|
|
||||||
while True:
|
|
||||||
# Wait for network events for a maximum of 15000 milliseconds (15 seconds)
|
|
||||||
events = poller.poll(15000)
|
|
||||||
|
|
||||||
if not events:
|
|
||||||
# The 15 seconds expired with zero network traffic!
|
|
||||||
# Send a keepalive ping to Mosquitto.
|
|
||||||
print("[Thread] No data for 15s. Sending keepalive ping...")
|
|
||||||
mqtt_client._client.ping()
|
|
||||||
else:
|
|
||||||
# Data has physically arrived on the socket!
|
|
||||||
# Calling wait() now is completely safe and won't block indefinitely.
|
|
||||||
mqtt_client.wait()
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("[Thread] Connection dropped or error encountered:", e)
|
print("[MQTT] Connection failed:", e)
|
||||||
print("[Thread] Cleaning up socket context. Retrying in 5 seconds...")
|
sys.print_exception(e)
|
||||||
try:
|
try:
|
||||||
mqtt_client.close()
|
mqtt_client.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
time.sleep(5)
|
|
||||||
|
|
||||||
# --- Launch background worker ---
|
# Force heap cleanup before sleeping
|
||||||
_thread.start_new_thread(mqtt_background_thread, ())
|
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()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if mqtt_connected:
|
||||||
|
try:
|
||||||
|
mqtt_client.poll()
|
||||||
|
now = time.time()
|
||||||
|
if now - last_ping >= 15:
|
||||||
|
mqtt_client.ping()
|
||||||
|
last_ping = now
|
||||||
|
except OSError as e:
|
||||||
|
print("[MQTT Task] Socket error encountered during poll/ping:", e)
|
||||||
|
mqtt_connected = False
|
||||||
|
await connect_mqtt_async()
|
||||||
|
|
||||||
|
await asyncio.sleep_ms(30)
|
||||||
|
|
||||||
|
|
||||||
# --- MAIN APPLICATION THREAD (Core 0) ---
|
async def orchestrator_hello_task():
|
||||||
print("[Main] Main execution path active.")
|
global mqtt_connected, should_unsubscribe_hello
|
||||||
while True:
|
while True:
|
||||||
# Your main physical loop runs completely unhindered here
|
if orchestrator_id is not None:
|
||||||
time.sleep(1)
|
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
|
||||||
|
|
||||||
|
if mqtt_connected:
|
||||||
|
print("[Hello Task] Sending initial hello to orchestrator...")
|
||||||
|
try:
|
||||||
|
if mqtt_client is None:
|
||||||
|
print("[Hello Task] MQTT client is None. Attempting to reconnect...")
|
||||||
|
await connect_mqtt_async()
|
||||||
|
|
||||||
|
mqtt_client.publish(
|
||||||
|
config.MQTT_TOPIC_HELLO,
|
||||||
|
payloads.mqtt_hello(DEVICE_ID),
|
||||||
|
qos=config.MQTT_QOS,
|
||||||
|
)
|
||||||
|
except OSError as e:
|
||||||
|
print("[Hello Task] Hello publish failed:", e)
|
||||||
|
# mqtt_connected = False
|
||||||
|
|
||||||
|
await asyncio.sleep(config.MQTT_HELLO_INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
|
async def memory_cleanup_task():
|
||||||
|
while True:
|
||||||
|
gc.collect()
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
|
||||||
|
|
||||||
|
# --- MAIN ENTRY POINT ---
|
||||||
|
async def main():
|
||||||
|
global sensor_request_event
|
||||||
|
print("[Main] Starting application...")
|
||||||
|
|
||||||
|
# Initialize loop-bound events
|
||||||
|
sensor_request_event = asyncio.Event()
|
||||||
|
|
||||||
|
await connect_mqtt_async()
|
||||||
|
init_hardware()
|
||||||
|
|
||||||
|
# Launch background tasks
|
||||||
|
asyncio.create_task(mqtt_poll_task())
|
||||||
|
asyncio.create_task(orchestrator_hello_task())
|
||||||
|
asyncio.create_task(sensor_publisher_task())
|
||||||
|
asyncio.create_task(uart_task())
|
||||||
|
asyncio.create_task(memory_cleanup_task())
|
||||||
|
|
||||||
|
print("[Main] All tasks running concurrently!")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(3600)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("[Main] Program stopped by user.")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import sensors.temperature_sensor as temperature_sensor
|
||||||
@@ -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.
@@ -5,7 +5,7 @@ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|||||||
REPO_ROOT=$(dirname -- "$SCRIPT_DIR")
|
REPO_ROOT=$(dirname -- "$SCRIPT_DIR")
|
||||||
PYTHON_BIN="${PYTHON_BIN:-python3}"
|
PYTHON_BIN="${PYTHON_BIN:-python3}"
|
||||||
PYTHON_SCRIPT="${1:-$SCRIPT_DIR/main.py}"
|
PYTHON_SCRIPT="${1:-$SCRIPT_DIR/main.py}"
|
||||||
REQUIREMENTS_FILE="$REPO_ROOT/requirements.txt"
|
REQUIREMENTS_FILE="$SCRIPT_DIR/requirements.txt"
|
||||||
|
|
||||||
if [ ! -f "$PYTHON_SCRIPT" ]; then
|
if [ ! -f "$PYTHON_SCRIPT" ]; then
|
||||||
echo "Python script not found: $PYTHON_SCRIPT" >&2
|
echo "Python script not found: $PYTHON_SCRIPT" >&2
|
||||||
@@ -19,7 +19,7 @@ cd "$SCRIPT_DIR"
|
|||||||
|
|
||||||
# 2. On lance Docker en arrière-plan
|
# 2. On lance Docker en arrière-plan
|
||||||
echo "Démarrage des conteneurs Docker..."
|
echo "Démarrage des conteneurs Docker..."
|
||||||
docker compose pull
|
# docker compose pull
|
||||||
docker compose up -d --remove-orphans
|
docker compose up -d --remove-orphans
|
||||||
|
|
||||||
# 3. Installation des dépendances (sans '--user' si on est déjà root sous systemd)
|
# 3. Installation des dépendances (sans '--user' si on est déjà root sous systemd)
|
||||||
|
|||||||
+393
-78
@@ -1,114 +1,429 @@
|
|||||||
import threading
|
import base64
|
||||||
import queue
|
import json
|
||||||
import time
|
import time
|
||||||
from shared import get_lora, get_mqtt_client, deviceTypes, config
|
import traceback
|
||||||
|
import asyncio
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from orchestrateur.sensors import gps
|
||||||
|
from shared import get_lora, get_mqtt_client, deviceTypes, config, payloads, db
|
||||||
|
from shared.logging import log
|
||||||
|
from shared.cookingState import CookingStates
|
||||||
|
from shared.lora_device import LoraCommands
|
||||||
|
from sensors import ultrasonicRanger, temp_hum, button, camera
|
||||||
|
|
||||||
|
# --- DB SETUP ---
|
||||||
|
DB_PATH = "orchestrateur/db.sqlite"
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
"""Ensures the connected_components table exists on startup."""
|
||||||
|
sql = """
|
||||||
|
CREATE TABLE IF NOT EXISTS connected_components (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT,
|
||||||
|
timestamp INTEGER
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
db.execute(DB_PATH, sql)
|
||||||
|
print(f"[DB] Initialized database table at {DB_PATH}")
|
||||||
|
|
||||||
|
def save_connected_component(component_id: str, component_type: str):
|
||||||
|
"""Upserts component information into the database (blocking sync worker)."""
|
||||||
|
current_time = int(time.time())
|
||||||
|
sql = """
|
||||||
|
INSERT INTO connected_components (id, type, timestamp)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
type = excluded.type,
|
||||||
|
timestamp = excluded.timestamp;
|
||||||
|
"""
|
||||||
|
db.execute(DB_PATH, sql, (str(component_id), str(component_type), current_time))
|
||||||
|
print(f"[DB] Component saved/updated -> ID: {component_id}, Type: {component_type}, Timestamp: {current_time}")
|
||||||
|
|
||||||
# --- 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
|
||||||
|
cloud_alert = False # Global status flag for screen / UI display
|
||||||
|
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,
|
||||||
)
|
)
|
||||||
mqtt_client.connect()
|
mqtt_client.connect()
|
||||||
|
mqtt_client.subscribe(config.MQTT_TOPIC_SENSOR, qos=config.MQTT_QOS)
|
||||||
|
mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS)
|
||||||
|
|
||||||
# --- 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:
|
||||||
print(f"\n[Thread MQTT] Message reçu : {message}")
|
try:
|
||||||
data_queue.put({"source": "MQTT", "data": message})
|
payload = json.loads(message['payload'])
|
||||||
|
except Exception:
|
||||||
|
payload = message['payload']
|
||||||
|
|
||||||
# --- THE CPU FIX ---
|
# --- SAFE TOPIC DECODING ---
|
||||||
# Sleep for 100ms. Prevents the thread from turning into an infinite 100% CPU hog.
|
topic = message['topic']
|
||||||
time.sleep(0.1)
|
if isinstance(topic, bytes):
|
||||||
|
topic = topic.decode('utf-8')
|
||||||
|
|
||||||
|
await async_event_queue.put({
|
||||||
|
"source": "MQTT",
|
||||||
|
"topic": topic,
|
||||||
|
"data": payload
|
||||||
|
})
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
# Launch background monitoring workers
|
def button_callback():
|
||||||
threading.Thread(target=lora_listener, daemon=True).start()
|
"""Button physical interrupt callback."""
|
||||||
threading.Thread(target=mqtt_listener, daemon=True).start()
|
global button_state
|
||||||
|
if microwave_states.get("2") == MicrowaveState.COOKING or microwave_states.get("2") == MicrowaveState.DONE:
|
||||||
|
print("[Button] Toggling pause/resume for microwave '2'.")
|
||||||
|
lora.send_reliable({"id": DEVICE_ID, "microwave_id": "2", "action": LoraCommands.TOGGLE_PAUSE})
|
||||||
|
else:
|
||||||
|
button_state = not button_state
|
||||||
|
print(f"[Button] Defrost state toggled to: {button_state}")
|
||||||
|
lora.send_reliable({"id": DEVICE_ID, "microwave_id": "2", "action": LoraCommands.TOGGLE_DEFROST, "defrost_state": button_state})
|
||||||
|
|
||||||
print("Orchestrateur prêt. Le main loop est libre.")
|
button.set_callback(button_callback)
|
||||||
|
button.start_button_monitoring_thread()
|
||||||
|
|
||||||
# --- MAIN EXECUTION LOOP ---
|
# --- HARDWARE CONTROLLERS ---
|
||||||
while True:
|
def _stop_hardware(microwave_id: str):
|
||||||
|
print(f"[{microwave_id}] /!\\ Emergency stop issued to hardware.")
|
||||||
|
# TODO: Add LoRa STOP command here
|
||||||
|
|
||||||
|
# --- ASYNC COOKING LOGIC ---
|
||||||
|
def read_local_sensors(microwave_id, initial_dish_height):
|
||||||
|
"""Blocking function to read local I2C/SPI sensors. Runs in a thread."""
|
||||||
|
print(f"[{microwave_id}] Reading local physical sensors...")
|
||||||
|
sensor_data = {
|
||||||
|
"microwave_id": microwave_id,
|
||||||
|
"defrost_mode": button_state,
|
||||||
|
"ultrasonic_distance": initial_dish_height # Reuse height from trigger
|
||||||
|
}
|
||||||
|
|
||||||
|
# Temp / Hum (handles DHT error safely)
|
||||||
try:
|
try:
|
||||||
# Check for non-heartbeat data safely
|
temp, hum = temp_hum.get_temperature_and_humidity_with_retry()
|
||||||
|
if temp is not None:
|
||||||
|
sensor_data["temperature"] = temp
|
||||||
|
sensor_data["humidity"] = hum
|
||||||
|
except Exception as e:
|
||||||
|
log(f"[{microwave_id}] DHT read warning: {e}")
|
||||||
|
|
||||||
|
# Camera
|
||||||
|
try:
|
||||||
|
sensor_data["camera_image"] = camera.get_picture()
|
||||||
|
except Exception as e:
|
||||||
|
log(f"[{microwave_id}] Camera read failed: {e}")
|
||||||
|
|
||||||
|
return sensor_data
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_new_dish(microwave_id, detected_height):
|
||||||
|
"""Triggered when a new dish is placed inside."""
|
||||||
|
microwave_states[microwave_id] = MicrowaveState.ANALYZING
|
||||||
|
print(f"\n[{microwave_id}] 🍽️ Dish detected at {detected_height:.1f} cm! Requesting IR from microwave...")
|
||||||
|
|
||||||
|
# 1. Setup synchronization event and clear previous cache for this microwave
|
||||||
|
event = asyncio.Event()
|
||||||
|
ir_data_events[microwave_id] = event
|
||||||
|
ir_data_cache.pop(microwave_id, None)
|
||||||
|
|
||||||
|
# 2. Send IR request to ESP32 via MQTT immediately
|
||||||
|
mqtt_client.publish(
|
||||||
|
config.MQTT_TOPIC_COOKING,
|
||||||
|
payloads.mqtt_cooking_init(microwave_id),
|
||||||
|
qos=config.MQTT_QOS
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Start local sensor reading in parallel
|
||||||
|
sensor_task = asyncio.create_task(asyncio.to_thread(read_local_sensors, microwave_id, detected_height))
|
||||||
|
|
||||||
|
# 4. Wait for local sensors to finish reading
|
||||||
|
sensors_data = await sensor_task
|
||||||
|
|
||||||
|
# Check if dish was removed while reading sensors
|
||||||
|
if microwave_states.get(microwave_id) != MicrowaveState.ANALYZING:
|
||||||
|
print(f"[{microwave_id}] Dish removed during sensor read. Aborting.")
|
||||||
|
ir_data_events.pop(microwave_id, None)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 5. Wait for MQTT IR data
|
||||||
|
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}")
|
||||||
|
# 6. Dispatch cloud request task
|
||||||
|
asyncio.create_task(request_cloud_cooking_plan(microwave_id, sensors_data))
|
||||||
|
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)
|
||||||
|
|
||||||
|
async def request_cloud_cooking_plan(microwave_id, sensors_data):
|
||||||
|
"""Sends all data to the cloud with up to 3 retries (30s interval)."""
|
||||||
|
global cloud_alert
|
||||||
|
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...")
|
||||||
|
|
||||||
|
max_retries = 3
|
||||||
|
retry_delay_seconds = 30
|
||||||
|
|
||||||
|
for attempt in range(1, max_retries + 1):
|
||||||
|
if microwave_states.get(microwave_id) != MicrowaveState.WAITING_FOR_CLOUD:
|
||||||
|
print(f"[{microwave_id}] Dish removed or state changed. Aborting API request.")
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
msg = data_queue.get(block=False)
|
print(f"[{microwave_id}] Connection attempt {attempt}/{max_retries}...")
|
||||||
print(f"\n[Main Loop] Données traitées : {msg['data']}")
|
response = await asyncio.to_thread(requests.post, URL, json=sensors_data, timeout=30)
|
||||||
except queue.Empty:
|
# Abort if state changed (e.g. user removed dish while waiting for wifi)
|
||||||
pass
|
if microwave_states.get(microwave_id) != MicrowaveState.WAITING_FOR_CLOUD:
|
||||||
|
print(f"[{microwave_id}] Dish removed during API request. Discarding API plan.")
|
||||||
|
return
|
||||||
|
|
||||||
time.sleep(1)
|
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")
|
||||||
|
|
||||||
# Publish debug telemetry message
|
if c_time is None or c_power is None or c_temp is None:
|
||||||
print("[Main Loop] Envoi d'un message de debug sur MQTT...")
|
print(f"[{microwave_id}] ❌ Invalid plan received: {response.json()}")
|
||||||
response = mqtt_client.publish(
|
microwave_states[microwave_id] = MicrowaveState.DONE
|
||||||
config.MQTT_TOPIC,
|
return
|
||||||
f"Orchestrateur actif, ID: {DEVICE_ID}",
|
|
||||||
qos=config.MQTT_QOS
|
|
||||||
)
|
|
||||||
|
|
||||||
# This will now unblock instantly because loop_start() handles the delivery confirmation!
|
print(f"[{microwave_id}] Cloud Plan Received! Starting microwave: {c_time}s @ {c_power}W")
|
||||||
response.wait_for_publish()
|
cloud_alert = False # Reset alert flag on success
|
||||||
print("[Main Loop] Message de debug publié avec succès.")
|
microwave_states[microwave_id] = MicrowaveState.COOKING
|
||||||
|
if config.DEBUG:
|
||||||
|
c_time = 20 # Set to 20s for debug
|
||||||
|
c_temp = 50 # Set to 50°C for debug
|
||||||
|
mqtt_client.publish(
|
||||||
|
config.MQTT_TOPIC_COOKING,
|
||||||
|
payloads.mqtt_cooking_config(microwave_id, c_time, c_power, c_temp),
|
||||||
|
qos=config.MQTT_QOS
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
time.sleep(9)
|
except Exception as e:
|
||||||
|
print(f"[{microwave_id}] Cloud API Error (Attempt {attempt}/{max_retries}): {e}")
|
||||||
|
|
||||||
|
if attempt < max_retries:
|
||||||
|
print(f"[{microwave_id}] Retrying in {retry_delay_seconds} seconds...")
|
||||||
|
# Interruptible wait loop in case user removes the dish mid-wait
|
||||||
|
for _ in range(retry_delay_seconds):
|
||||||
|
if microwave_states.get(microwave_id) != MicrowaveState.WAITING_FOR_CLOUD:
|
||||||
|
print(f"[{microwave_id}] State changed during retry wait. Aborting retries.")
|
||||||
|
return
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
# Executed only if all 3 retries failed
|
||||||
|
print(f"[{microwave_id}] All 3 cloud retries failed. Setting global alert flag.")
|
||||||
|
cloud_alert = True
|
||||||
|
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:
|
||||||
|
component_id = data.get("id_microwave")
|
||||||
|
component_type = data.get("type", deviceTypes.DEVICE_TYPES["MICROWAVE"])
|
||||||
|
|
||||||
|
if component_id:
|
||||||
|
print(f"[MQTT] Hello received from '{component_id}' ({component_type}). Updating DB & sending ACK.")
|
||||||
|
# Offload DB insertion to async thread execution pool
|
||||||
|
await asyncio.to_thread(save_connected_component, component_id, component_type)
|
||||||
|
|
||||||
|
mqtt_client.publish(
|
||||||
|
config.MQTT_TOPIC_HELLO,
|
||||||
|
payloads.mqtt_hello_ack(DEVICE_ID, component_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 negative glitches
|
||||||
|
if h is not None and h >= 0.0:
|
||||||
|
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."""
|
||||||
|
global cloud_alert
|
||||||
|
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
|
||||||
|
cloud_alert = False # Reset error alert on dish removal
|
||||||
|
|
||||||
|
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...")
|
||||||
|
|
||||||
|
# Initialize SQLite database table
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
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:
|
except KeyboardInterrupt:
|
||||||
break
|
print("\nArrêt manuel.")
|
||||||
|
finally:
|
||||||
# Clean termination
|
if hasattr(mqtt_client._client, "loop_stop"):
|
||||||
if hasattr(mqtt_client._client, "loop_stop"):
|
mqtt_client._client.loop_stop()
|
||||||
mqtt_client._client.loop_stop()
|
mqtt_client.close()
|
||||||
mqtt_client.close()
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
paho-mqtt>=1.6,<3
|
||||||
|
pyserial>=3.5,<4
|
||||||
|
# picamera2>=0.3.36,<4 # → Installed with apt install python3-picamera2
|
||||||
|
# OpenCV
|
||||||
|
# sudo apt install -y python3-opencv
|
||||||
|
# sudo apt install -y opencv-data
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# import grovepi
|
||||||
|
|
||||||
|
import sensors.ultrasonicRanger as ultrasonicRanger
|
||||||
|
import sensors.temp_hum as temp_hum
|
||||||
|
import sensors.button as button
|
||||||
|
import sensors.gps as gps
|
||||||
|
import sensors.camera as camera
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import grovepi
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
from sensors.lock import grove_lock
|
||||||
|
from shared.logging import log
|
||||||
|
|
||||||
|
button = 2
|
||||||
|
button_switch_state = 0
|
||||||
|
grovepi.pinMode(button, "INPUT")
|
||||||
|
|
||||||
|
button_callback = None
|
||||||
|
|
||||||
|
def read_button_state():
|
||||||
|
# Increase timeout slightly so the button thread can wait for long I2C sensor reads to finish
|
||||||
|
if not grove_lock.acquire(timeout=0.2):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return grovepi.digitalRead(button)
|
||||||
|
except Exception as e:
|
||||||
|
log(f"BTN Error: {e}")
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
grove_lock.release()
|
||||||
|
|
||||||
|
def monitor_button():
|
||||||
|
global button_switch_state
|
||||||
|
last_button_state = button_switch_state
|
||||||
|
|
||||||
|
while True:
|
||||||
|
current_state = read_button_state()
|
||||||
|
|
||||||
|
if current_state is not None:
|
||||||
|
# Rising edge detection (0 -> 1 transition)
|
||||||
|
if current_state == 1 and last_button_state == 0:
|
||||||
|
if button_callback:
|
||||||
|
button_callback()
|
||||||
|
last_button_state = current_state
|
||||||
|
time.sleep(0.02) # Fast 20ms poll when lock is clear
|
||||||
|
else:
|
||||||
|
# Lock was busy; retry quickly without updating last_button_state
|
||||||
|
time.sleep(0.01)
|
||||||
|
|
||||||
|
def start_button_monitoring_thread():
|
||||||
|
threading.Thread(target=monitor_button, daemon=True).start()
|
||||||
|
|
||||||
|
def set_callback(callback):
|
||||||
|
global button_callback
|
||||||
|
button_callback = callback
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import grovepi
|
||||||
|
import math
|
||||||
|
from sensors.lock import grove_lock
|
||||||
|
from picamera2 import Picamera2, Preview
|
||||||
|
import time
|
||||||
|
|
||||||
|
picam2 = Picamera2()
|
||||||
|
|
||||||
|
camera_config = picam2.create_still_configuration()
|
||||||
|
picam2.configure(camera_config)
|
||||||
|
|
||||||
|
picam2.start()
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
def preview_camera():
|
||||||
|
picam2.start_preview(Preview.DRM)
|
||||||
|
|
||||||
|
def stop_preview_camera():
|
||||||
|
picam2.stop_preview()
|
||||||
|
|
||||||
|
def take_picture():
|
||||||
|
"""Takes a picture and saves it to the file system"""
|
||||||
|
picam2.capture_file("test.jpg")
|
||||||
|
return "test.jpg"
|
||||||
|
|
||||||
|
def get_picture():
|
||||||
|
"""Returns the image bytes as base64
|
||||||
|
"""
|
||||||
|
file_path = take_picture()
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
image_bytes = f.read()
|
||||||
|
return image_bytes
|
||||||
|
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import serial
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
from shared.logging import log
|
||||||
|
from sensors.lock import serial_lock
|
||||||
|
|
||||||
|
def calculate_nmea_checksum(line: str) -> bool:
|
||||||
|
"""Validates standard NMEA 0183 sentence checksum ($...*HH)."""
|
||||||
|
if not line.startswith('$') or '*' not in line:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
content, checksum_str = line[1:].split('*', 1)
|
||||||
|
calculated_checksum = 0
|
||||||
|
for char in content:
|
||||||
|
calculated_checksum ^= ord(char)
|
||||||
|
|
||||||
|
return calculated_checksum == int(checksum_str[:2], 16)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class GROVEGPS:
|
||||||
|
def __init__(self, port='/dev/ttyAMA0', baud=9600, timeout=1):
|
||||||
|
self.ser = serial.Serial(port, baud, timeout=timeout)
|
||||||
|
self.clean_data()
|
||||||
|
|
||||||
|
def clean_data(self):
|
||||||
|
self.timestamp = ""
|
||||||
|
self.quality = 0
|
||||||
|
self.satellites = 0
|
||||||
|
self.altitude = -1.0
|
||||||
|
self.latitude = -1.0
|
||||||
|
self.longitude = -1.0
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
"""Reads the latest GGA sentence from serial, thread-safely."""
|
||||||
|
with serial_lock:
|
||||||
|
# 1. Flush accumulated stale data in the UART buffer
|
||||||
|
if self.ser.in_waiting > 0:
|
||||||
|
self.ser.reset_input_buffer()
|
||||||
|
|
||||||
|
# 2. Try reading up to 15 lines to catch the freshest GGA sentence
|
||||||
|
for _ in range(5):
|
||||||
|
raw_bytes = self.ser.readline()
|
||||||
|
try:
|
||||||
|
line = raw_bytes.decode('utf-8', errors='ignore').strip()
|
||||||
|
# log(f"GPS: Read line: {line}")
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Supports both $GPGGA and modern $GNGGA sentences
|
||||||
|
if (line.startswith('$GPGGA') or line.startswith('$GNGGA')) and calculate_nmea_checksum(line):
|
||||||
|
if self.parse_gga(line):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def parse_gga(self, line):
|
||||||
|
self.clean_data()
|
||||||
|
gga = line.split(',')
|
||||||
|
|
||||||
|
if len(gga) < 10:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.timestamp = gga[1]
|
||||||
|
self.quality = int(gga[6]) if gga[6] != "" else 0
|
||||||
|
self.satellites = int(gga[7]) if gga[7] != "" else 0
|
||||||
|
|
||||||
|
# If quality > 0 and coordinates exist, convert NMEA DDDMM.MMMM to decimal degrees
|
||||||
|
if self.quality > 0 and gga[2] != "" and gga[4] != "":
|
||||||
|
lat_raw = float(gga[2])
|
||||||
|
ns = gga[3]
|
||||||
|
lon_raw = float(gga[4])
|
||||||
|
ew = gga[5]
|
||||||
|
|
||||||
|
# Latitude calculation
|
||||||
|
lat_deg = lat_raw // 100
|
||||||
|
lat_min = lat_raw % 100
|
||||||
|
self.latitude = lat_deg + (lat_min / 60.0)
|
||||||
|
if ns == 'S':
|
||||||
|
self.latitude = -self.latitude
|
||||||
|
|
||||||
|
# Longitude calculation
|
||||||
|
lon_deg = lon_raw // 100
|
||||||
|
lon_min = lon_raw % 100
|
||||||
|
self.longitude = lon_deg + (lon_min / 60.0)
|
||||||
|
if ew == 'W':
|
||||||
|
self.longitude = -self.longitude
|
||||||
|
|
||||||
|
self.altitude = float(gga[9]) if gga[9] != "" else -1.0
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
# No lock on this line
|
||||||
|
return True
|
||||||
|
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Shared instance
|
||||||
|
gps = GROVEGPS()
|
||||||
|
|
||||||
|
def get_gps_data():
|
||||||
|
"""Returns GPS dictionary if fix is valid, otherwise returns None."""
|
||||||
|
has_data = gps.read()
|
||||||
|
|
||||||
|
# Strictly check that we have a valid GPS lock (quality > 0 and valid coordinates)
|
||||||
|
if has_data and gps.quality > 0 and gps.latitude != -1.0:
|
||||||
|
return {
|
||||||
|
"timestamp": gps.timestamp,
|
||||||
|
"latitude": round(gps.latitude, 6),
|
||||||
|
"longitude": round(gps.longitude, 6),
|
||||||
|
"altitude": gps.altitude,
|
||||||
|
"quality": gps.quality,
|
||||||
|
"satellites": gps.satellites
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
log(f"GPS: No valid fix or data available. Satellites: {gps.satellites}, Quality: {gps.quality}")
|
||||||
|
|
||||||
|
# Return None so main.py doesn't process or log empty GPS data
|
||||||
|
return None
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# import orchestrateur.sensors.lib.grovepi_old as grovepi_old
|
||||||
|
# import sensors.lib.grove_i2c_temp_hum_mini as grove_i2c_temp_hum_mini
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
#
|
||||||
|
# GrovePi Library for using the Grove - Temperature&Humidity Sensor (http://www.seeedstudio.com/depot/Grove-TemperatureHumidity-Sensor-HighAccuracy-Mini-p-1921.html)
|
||||||
|
#
|
||||||
|
# The GrovePi connects the Raspberry Pi and Grove sensors. You can learn more about GrovePi here: http://www.dexterindustries.com/GrovePi
|
||||||
|
#
|
||||||
|
# Have a question about this library? Ask on the forums here: http://forum.dexterindustries.com/c/grovepi
|
||||||
|
#
|
||||||
|
# Released under the MIT license (http://choosealicense.com/licenses/mit/).
|
||||||
|
# For more information see https://github.com/DexterInd/GrovePi/blob/master/LICENSE
|
||||||
|
#################################################################################################################################################
|
||||||
|
# NOTE:
|
||||||
|
# The software for this sensor is still in development and might make your GrovePi unuable as long as this sensor is connected with the GrovePi
|
||||||
|
#################################################################################################################################################
|
||||||
|
import time,sys
|
||||||
|
import RPi.GPIO as GPIO
|
||||||
|
import smbus
|
||||||
|
from shared import config
|
||||||
|
|
||||||
|
debug = config.DEBUG
|
||||||
|
# use the bus that matches your raspi version
|
||||||
|
rev = GPIO.RPI_REVISION
|
||||||
|
if rev == 2 or rev == 3:
|
||||||
|
bus = smbus.SMBus(1)
|
||||||
|
else:
|
||||||
|
bus = smbus.SMBus(0)
|
||||||
|
|
||||||
|
class th02:
|
||||||
|
|
||||||
|
ADDRESS = 0x40
|
||||||
|
|
||||||
|
TH02_REG_STATUS = 0x00
|
||||||
|
TH02_REG_DATA_H = 0x01
|
||||||
|
TH02_REG_DATA_L = 0x02
|
||||||
|
TH02_REG_CONFIG = 0x03
|
||||||
|
TH02_REG_ID = 0x11
|
||||||
|
|
||||||
|
TH02_STATUS_RDY_MASK = 0x01
|
||||||
|
|
||||||
|
TH02_CMD_MEASURE_HUMI = [0x01]
|
||||||
|
TH02_CMD_MEASURE_TEMP = [0x11]
|
||||||
|
|
||||||
|
SUCCESS = 0
|
||||||
|
|
||||||
|
def getTemperature(self):
|
||||||
|
bus.write_i2c_block_data(self.ADDRESS, self.TH02_REG_CONFIG, self.TH02_CMD_MEASURE_TEMP)
|
||||||
|
|
||||||
|
while 1:
|
||||||
|
status=self.getStatus()
|
||||||
|
if debug:
|
||||||
|
print("st:",status)
|
||||||
|
if status:
|
||||||
|
break
|
||||||
|
t_raw=bus.read_i2c_block_data(self.ADDRESS, self.TH02_REG_DATA_H,3)
|
||||||
|
if debug:
|
||||||
|
print(t_raw)
|
||||||
|
temperature = (t_raw[1]<<8|t_raw[2])>>2
|
||||||
|
return (temperature/32.0)-50.0
|
||||||
|
|
||||||
|
def getHumidity(self):
|
||||||
|
bus.write_i2c_block_data(self.ADDRESS, self.TH02_REG_CONFIG, self.TH02_CMD_MEASURE_HUMI)
|
||||||
|
|
||||||
|
while 1:
|
||||||
|
status=self.getStatus()
|
||||||
|
if debug:
|
||||||
|
print("st:",status)
|
||||||
|
if status:
|
||||||
|
break
|
||||||
|
t_raw=bus.read_i2c_block_data(self.ADDRESS, self.TH02_REG_DATA_H,3)
|
||||||
|
if debug:
|
||||||
|
print(t_raw)
|
||||||
|
temperature = (t_raw[1]<<8|t_raw[2])>>4
|
||||||
|
return (temperature/16.0)-24.0
|
||||||
|
|
||||||
|
def getStatus(self):
|
||||||
|
status=bus.read_i2c_block_data(self.ADDRESS, self.TH02_REG_STATUS,1)
|
||||||
|
if debug:
|
||||||
|
print(status)
|
||||||
|
if status[0] & self.TH02_STATUS_RDY_MASK != 1:
|
||||||
|
return 1
|
||||||
|
else:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
t= th02()
|
||||||
|
while True:
|
||||||
|
print(t.getTemperature(),t.getHumidity())
|
||||||
|
time.sleep(.5)
|
||||||
@@ -0,0 +1,691 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
#
|
||||||
|
# GrovePi Python library
|
||||||
|
# v1.4
|
||||||
|
#
|
||||||
|
# This file provides the basic functions for using the GrovePi
|
||||||
|
#
|
||||||
|
# The GrovePi connects the Raspberry Pi and Grove sensors. You can learn more about GrovePi here: http://www.dexterindustries.com/GrovePi
|
||||||
|
#
|
||||||
|
# Have a question about this example? Ask on the forums here: http://forum.dexterindustries.com/c/grovepi
|
||||||
|
#
|
||||||
|
'''
|
||||||
|
## License
|
||||||
|
|
||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
GrovePi for the Raspberry Pi: an open source platform for connecting Grove Sensors to the Raspberry Pi.
|
||||||
|
Copyright (C) 2017 Dexter Industries
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
'''
|
||||||
|
# Initial Date: 13 Feb 2014
|
||||||
|
# Last Updated: 11 Nov 2016
|
||||||
|
# http://www.dexterindustries.com/
|
||||||
|
# Author Date Comments
|
||||||
|
# Karan 13 Feb 2014 Initial Authoring
|
||||||
|
# 11 Nov 2016 I2C retries added for faster IO
|
||||||
|
# DHT function updated to look for nan's
|
||||||
|
|
||||||
|
__version__ = '1.4.1'
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import math
|
||||||
|
import struct
|
||||||
|
import numpy
|
||||||
|
|
||||||
|
import di_i2c
|
||||||
|
|
||||||
|
def set_bus(bus):
|
||||||
|
global i2c
|
||||||
|
i2c = di_i2c.DI_I2C(bus = bus, address = address)
|
||||||
|
|
||||||
|
address = 0x04
|
||||||
|
max_recv_size = 10
|
||||||
|
set_bus("RPI_1SW")
|
||||||
|
|
||||||
|
if sys.version_info<(3,0):
|
||||||
|
p_version = 2
|
||||||
|
else:
|
||||||
|
p_version = 3
|
||||||
|
|
||||||
|
# Earliest version of the firmware to work with
|
||||||
|
works_with_firmware = [
|
||||||
|
"1.4.0"
|
||||||
|
]
|
||||||
|
|
||||||
|
# interrupt operations
|
||||||
|
COUNT_CHANGES = 0
|
||||||
|
COUNT_LOW_DURATION = 1
|
||||||
|
|
||||||
|
# interrupt trigger mode
|
||||||
|
CHANGE = 1
|
||||||
|
FALLING = 2
|
||||||
|
RISING = 3
|
||||||
|
|
||||||
|
# This allows us to be more specific about which commands contain unused bytes
|
||||||
|
unused = 0
|
||||||
|
retries = 10
|
||||||
|
additional_waiting = 0
|
||||||
|
|
||||||
|
# Get firmware version
|
||||||
|
version_cmd = [8]
|
||||||
|
# No data is available from the GrovePi
|
||||||
|
data_not_available_cmd = [23]
|
||||||
|
|
||||||
|
# Command Format
|
||||||
|
# digitalRead() command format header
|
||||||
|
dRead_cmd = [1]
|
||||||
|
# digitalWrite() command format header
|
||||||
|
dWrite_cmd = [2]
|
||||||
|
# analogRead() command format header
|
||||||
|
aRead_cmd = [3]
|
||||||
|
# analogWrite() command format header
|
||||||
|
aWrite_cmd = [4]
|
||||||
|
# pinMode() command format header
|
||||||
|
pMode_cmd = [5]
|
||||||
|
# Ultrasonic read
|
||||||
|
uRead_cmd = [7]
|
||||||
|
# Accelerometer (+/- 1.5g) read
|
||||||
|
acc_xyz_cmd = [20]
|
||||||
|
# RTC get time
|
||||||
|
rtc_getTime_cmd = [30]
|
||||||
|
# DHT Pro sensor temperature
|
||||||
|
dht_temp_cmd = [40]
|
||||||
|
|
||||||
|
# Grove LED Bar commands
|
||||||
|
# Initialise
|
||||||
|
ledBarInit_cmd = [50]
|
||||||
|
# Set orientation
|
||||||
|
ledBarOrient_cmd = [51]
|
||||||
|
# Set level
|
||||||
|
ledBarLevel_cmd = [52]
|
||||||
|
# Set single LED
|
||||||
|
ledBarSetOne_cmd = [53]
|
||||||
|
# Toggle single LED
|
||||||
|
ledBarToggleOne_cmd = [54]
|
||||||
|
# Set all LEDs
|
||||||
|
ledBarSet_cmd = [55]
|
||||||
|
# Get current state
|
||||||
|
ledBarGet_cmd = [56]
|
||||||
|
|
||||||
|
# Grove 4 Digit Display commands
|
||||||
|
# Initialise
|
||||||
|
fourDigitInit_cmd = [70]
|
||||||
|
# Set brightness, not visible until next cmd
|
||||||
|
fourDigitBrightness_cmd = [71]
|
||||||
|
# Set numeric value without leading zeros
|
||||||
|
fourDigitValue_cmd = [72]
|
||||||
|
# Set numeric value with leading zeros
|
||||||
|
fourDigitValueZeros_cmd = [73]
|
||||||
|
# Set individual digit
|
||||||
|
fourDigitIndividualDigit_cmd = [74]
|
||||||
|
# Set individual leds of a segment
|
||||||
|
fourDigitIndividualLeds_cmd = [75]
|
||||||
|
# Set left and right values with colon
|
||||||
|
fourDigitScore_cmd = [76]
|
||||||
|
# Analog read for n seconds
|
||||||
|
fourDigitAnalogRead_cmd = [77]
|
||||||
|
# Entire display on
|
||||||
|
fourDigitAllOn_cmd = [78]
|
||||||
|
# Entire display off
|
||||||
|
fourDigitAllOff_cmd = [79]
|
||||||
|
|
||||||
|
# Grove Chainable RGB LED commands
|
||||||
|
# Store color for later use
|
||||||
|
storeColor_cmd = [90]
|
||||||
|
# Initialise
|
||||||
|
chainableRgbLedInit_cmd = [91]
|
||||||
|
# Initialise and test with a simple color
|
||||||
|
chainableRgbLedTest_cmd = [92]
|
||||||
|
# Set one or more leds to the stored color by pattern
|
||||||
|
chainableRgbLedSetPattern_cmd = [93]
|
||||||
|
# set one or more leds to the stored color by modulo
|
||||||
|
chainableRgbLedSetModulo_cmd = [94]
|
||||||
|
# sets leds similar to a bar graph, reversible
|
||||||
|
chainableRgbLedSetLevel_cmd = [95]
|
||||||
|
|
||||||
|
# Read the button from IR sensor
|
||||||
|
ir_read_cmd = [21]
|
||||||
|
# Set pin for the IR receiver
|
||||||
|
ir_recv_pin_cmd = [22]
|
||||||
|
# Check if there's data coming from the IR receiver
|
||||||
|
ir_read_isdata = [24]
|
||||||
|
|
||||||
|
# Interrupt-based devices
|
||||||
|
isr_set_cmd = [6]
|
||||||
|
isr_unset_cmd = [9]
|
||||||
|
isr_read_cmd = [10]
|
||||||
|
isr_clear_cmd = [11]
|
||||||
|
isr_active_cmd = [12]
|
||||||
|
|
||||||
|
# Grove Encoders
|
||||||
|
encoder_read_cmd = [13]
|
||||||
|
encoder_en_cmd = [14]
|
||||||
|
encoder_dis_cmd = [15]
|
||||||
|
|
||||||
|
# Dust, Encoder & Flow Sensor commands
|
||||||
|
# dust_sensor_read_cmd=[10]
|
||||||
|
# dust_sensor_en_cmd=[14]
|
||||||
|
# dust_sensor_dis_cmd=[15]
|
||||||
|
# dust_sensor_int_cmd=[9]
|
||||||
|
# dust_sensor_read_int_cmd=[6]
|
||||||
|
# flow_read_cmd=[12]
|
||||||
|
# flow_disable_cmd=[13]
|
||||||
|
# flow_en_cmd=[18]
|
||||||
|
|
||||||
|
|
||||||
|
# Function declarations of the various functions used for encoding and sending
|
||||||
|
# data from RPi to Arduino
|
||||||
|
|
||||||
|
# Write I2C block to the GrovePi
|
||||||
|
def write_i2c_block(block, custom_timing = None):
|
||||||
|
'''
|
||||||
|
Now catches and raises Keyboard Interrupt that the user is responsible to catch.
|
||||||
|
'''
|
||||||
|
counter = 0
|
||||||
|
reg = block[0]
|
||||||
|
data = block[1:]
|
||||||
|
while counter < 3:
|
||||||
|
try:
|
||||||
|
i2c.write_reg_list(reg, data)
|
||||||
|
time.sleep(0.002 + additional_waiting)
|
||||||
|
return
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
except:
|
||||||
|
counter += 1
|
||||||
|
time.sleep(0.003)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Read I2C block from the GrovePi
|
||||||
|
def read_i2c_block(no_bytes = max_recv_size):
|
||||||
|
'''
|
||||||
|
Now catches and raises Keyboard Interrupt that the user is responsible to catch.
|
||||||
|
'''
|
||||||
|
data = data_not_available_cmd
|
||||||
|
counter = 0
|
||||||
|
while data[0] in [data_not_available_cmd[0], 255] and counter < 3:
|
||||||
|
try:
|
||||||
|
data = i2c.read_list(reg = None, len = no_bytes)
|
||||||
|
time.sleep(0.002 + additional_waiting)
|
||||||
|
if counter > 0:
|
||||||
|
counter = 0
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
except:
|
||||||
|
counter += 1
|
||||||
|
time.sleep(0.003)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def read_identified_i2c_block(read_command_id, no_bytes):
|
||||||
|
data = [-1]
|
||||||
|
while len(data) <= 1:
|
||||||
|
data = read_i2c_block(no_bytes + 1)
|
||||||
|
|
||||||
|
return data[1:]
|
||||||
|
|
||||||
|
# Arduino Digital Read
|
||||||
|
def digitalRead(pin):
|
||||||
|
write_i2c_block(dRead_cmd + [pin, unused, unused])
|
||||||
|
data = read_identified_i2c_block( dRead_cmd, no_bytes = 1)[0]
|
||||||
|
return data
|
||||||
|
|
||||||
|
# Arduino Digital Write
|
||||||
|
def digitalWrite(pin, value):
|
||||||
|
write_i2c_block(dWrite_cmd + [pin, value, unused])
|
||||||
|
read_i2c_block(no_bytes = 1)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Read analog value from Pin
|
||||||
|
def analogRead(pin):
|
||||||
|
write_i2c_block(aRead_cmd + [pin, unused, unused])
|
||||||
|
number = read_identified_i2c_block(aRead_cmd, no_bytes = 2)
|
||||||
|
return number[0] * 256 + number[1]
|
||||||
|
|
||||||
|
|
||||||
|
# Write PWM
|
||||||
|
def analogWrite(pin, value):
|
||||||
|
write_i2c_block(aWrite_cmd + [pin, value, unused])
|
||||||
|
read_i2c_block(no_bytes = 1)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Setting Up Pin mode on Arduino
|
||||||
|
def pinMode(pin, mode):
|
||||||
|
if mode == "OUTPUT":
|
||||||
|
write_i2c_block(pMode_cmd + [pin, 1, unused])
|
||||||
|
elif mode == "INPUT":
|
||||||
|
write_i2c_block(pMode_cmd + [pin, 0, unused])
|
||||||
|
read_i2c_block(no_bytes = 1)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
# Read temp in Celsius from Grove Temperature Sensor
|
||||||
|
def temp(pin, model = '1.0'):
|
||||||
|
# each of the sensor revisions use different thermistors, each with their own B value constant
|
||||||
|
if model == '1.2':
|
||||||
|
bValue = 4250 # sensor v1.2 uses thermistor ??? (assuming NCP18WF104F03RC until SeeedStudio clarifies)
|
||||||
|
elif model == '1.1':
|
||||||
|
bValue = 4250 # sensor v1.1 uses thermistor NCP18WF104F03RC
|
||||||
|
else:
|
||||||
|
bValue = 3975 # sensor v1.0 uses thermistor TTC3A103*39H
|
||||||
|
a = analogRead(pin)
|
||||||
|
resistance = (float)(1023 - a) * 10000 / a
|
||||||
|
t = (float)(1 / (math.log(resistance / 10000) / bValue + 1 / 298.15) - 273.15)
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
# Read value from Grove Ultrasonic
|
||||||
|
def ultrasonicRead(pin):
|
||||||
|
write_i2c_block(uRead_cmd + [pin, unused, unused])
|
||||||
|
number = read_identified_i2c_block(uRead_cmd, no_bytes = 2)
|
||||||
|
return (number[0] * 256 + number[1])
|
||||||
|
|
||||||
|
|
||||||
|
# Read the firmware version
|
||||||
|
def version():
|
||||||
|
write_i2c_block(version_cmd + [unused, unused, unused])
|
||||||
|
number = read_identified_i2c_block(version_cmd, no_bytes = 3)
|
||||||
|
return "%s.%s.%s" % (number[0], number[1], number[2])
|
||||||
|
|
||||||
|
|
||||||
|
# Read Grove Accelerometer (+/- 1.5g) XYZ value
|
||||||
|
# Need to investigate why this reports what was read with the previous command
|
||||||
|
# Doesn't look to be implemented on the GrovePi
|
||||||
|
def acc_xyz():
|
||||||
|
write_i2c_block(acc_xyz_cmd + [unused, unused, unused])
|
||||||
|
number = read_identified_i2c_block(acc_xyz_cmd, no_bytes = 3)
|
||||||
|
if number[1] > 32:
|
||||||
|
number[1] = - (number[1] - 224)
|
||||||
|
if number[2] > 32:
|
||||||
|
number[2] = - (number[2] - 224)
|
||||||
|
if number[3] > 32:
|
||||||
|
number[3] = - (number[3] - 224)
|
||||||
|
return (number[0], number[1], number[2])
|
||||||
|
|
||||||
|
|
||||||
|
# Read from Grove RTC
|
||||||
|
# Doesn't look to be implemented on the GrovePi
|
||||||
|
def rtc_getTime():
|
||||||
|
write_i2c_block(rtc_getTime_cmd + [unused, unused, unused])
|
||||||
|
number = read_i2c_block()
|
||||||
|
return number
|
||||||
|
|
||||||
|
# Read and return temperature and humidity from Grove DHT Pro
|
||||||
|
def dht(pin, module_type):
|
||||||
|
write_i2c_block(dht_temp_cmd + [pin, module_type, unused])
|
||||||
|
number = read_identified_i2c_block(dht_temp_cmd, no_bytes = 8)
|
||||||
|
|
||||||
|
if p_version==2:
|
||||||
|
h=''
|
||||||
|
for element in (number[0:4]):
|
||||||
|
h+=chr(element)
|
||||||
|
|
||||||
|
t_val=struct.unpack('f', h)
|
||||||
|
t = round(t_val[0], 2)
|
||||||
|
|
||||||
|
h = ''
|
||||||
|
for element in (number[4:8]):
|
||||||
|
h+=chr(element)
|
||||||
|
|
||||||
|
hum_val=struct.unpack('f',h)
|
||||||
|
hum = round(hum_val[0], 2)
|
||||||
|
else:
|
||||||
|
t_val=bytearray(number[0:4])
|
||||||
|
h_val=bytearray(number[4:8])
|
||||||
|
t=round(struct.unpack('f',t_val)[0],2)
|
||||||
|
hum=round(struct.unpack('f',h_val)[0],2)
|
||||||
|
if t > -100.0 and t <150.0 and hum >= 0.0 and hum<=100.0:
|
||||||
|
return [t, hum]
|
||||||
|
else:
|
||||||
|
return [float('nan'),float('nan')]
|
||||||
|
|
||||||
|
# Grove - Infrared Receiver - get the commands received from the Grove IR sensor
|
||||||
|
def ir_read_signal():
|
||||||
|
write_i2c_block(ir_read_cmd + [unused, unused, unused])
|
||||||
|
data_back = read_identified_i2c_block(ir_read_cmd, no_bytes = 7)
|
||||||
|
|
||||||
|
return (data_back[0],
|
||||||
|
data_back[1] + data_back[2] * 256,
|
||||||
|
data_back[3] + data_back[4] * 256 + data_back[5] * (256 ** 2) + data_back[6] * (256 ** 3))
|
||||||
|
|
||||||
|
# Grove - Infrared Receiver - set the pin on which the Grove IR sensor is connected
|
||||||
|
def ir_recv_pin(pin):
|
||||||
|
write_i2c_block(ir_recv_pin_cmd + [pin, unused, unused])
|
||||||
|
read_i2c_block(no_bytes = 1)
|
||||||
|
|
||||||
|
# Grove - Infrared Receiver - check if there's any data that hasn't been read so far
|
||||||
|
def ir_is_data():
|
||||||
|
write_i2c_block(ir_read_isdata + 3 * [unused])
|
||||||
|
number = read_identified_i2c_block(ir_read_isdata, no_bytes = 1)
|
||||||
|
|
||||||
|
return number[0] != 0
|
||||||
|
|
||||||
|
# after a list of numerical values is provided
|
||||||
|
# the function returns a list with the outlier(or extreme) values removed
|
||||||
|
# make the std_factor_threshold bigger so that filtering becomes less strict
|
||||||
|
# and make the std_factor_threshold smaller to get the opposite
|
||||||
|
def statisticalNoiseReduction(values, std_factor_threshold = 2):
|
||||||
|
if len(values) == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
mean = numpy.mean(values)
|
||||||
|
standard_deviation = numpy.std(values)
|
||||||
|
|
||||||
|
if standard_deviation == 0:
|
||||||
|
return values
|
||||||
|
|
||||||
|
filtered_values = [element for element in values if element > mean - std_factor_threshold * standard_deviation]
|
||||||
|
filtered_values = [element for element in filtered_values if element < mean + std_factor_threshold * standard_deviation]
|
||||||
|
|
||||||
|
return filtered_values
|
||||||
|
|
||||||
|
|
||||||
|
# Grove LED Bar - initialise
|
||||||
|
# orientation: (0 = red to green, 1 = green to red)
|
||||||
|
def ledBar_init(pin, orientation):
|
||||||
|
write_i2c_block(ledBarInit_cmd + [pin, orientation, unused])
|
||||||
|
read_i2c_block(no_bytes = 1)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Grove LED Bar - set orientation
|
||||||
|
# orientation: (0 = red to green, 1 = green to red)
|
||||||
|
def ledBar_orientation(pin, orientation):
|
||||||
|
write_i2c_block(ledBarOrient_cmd + [pin, orientation, unused])
|
||||||
|
read_i2c_block(no_bytes = 1)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Grove LED Bar - set level
|
||||||
|
# level: (0-10)
|
||||||
|
def ledBar_setLevel(pin, level):
|
||||||
|
write_i2c_block(ledBarLevel_cmd + [pin, level, unused])
|
||||||
|
read_i2c_block(no_bytes = 1)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Grove LED Bar - set single led
|
||||||
|
# led: which led (1-10)
|
||||||
|
# state: off or on (0-1)
|
||||||
|
def ledBar_setLed(pin, led, state):
|
||||||
|
write_i2c_block(ledBarSetOne_cmd + [pin, led, state])
|
||||||
|
read_i2c_block(no_bytes = 1)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Grove LED Bar - toggle single led
|
||||||
|
# led: which led (1-10)
|
||||||
|
def ledBar_toggleLed(pin, led):
|
||||||
|
write_i2c_block(ledBarToggleOne_cmd + [pin, led, unused])
|
||||||
|
read_i2c_block(no_bytes = 1)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Grove LED Bar - set all leds
|
||||||
|
# state: (0-1023) or (0x00-0x3FF) or (0b0000000000-0b1111111111) or (int('0000000000',2)-int('1111111111',2))
|
||||||
|
def ledBar_setBits(pin, state):
|
||||||
|
byte1 = state & 255
|
||||||
|
byte2 = state >> 8
|
||||||
|
write_i2c_block(ledBarSet_cmd + [pin, byte1, byte2])
|
||||||
|
read_i2c_block(no_bytes = 1)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Grove LED Bar - get current state
|
||||||
|
# state: (0-1023) a bit for each of the 10 LEDs
|
||||||
|
def ledBar_getBits(pin):
|
||||||
|
write_i2c_block(ledBarGet_cmd + [pin, unused, unused])
|
||||||
|
block = read_identified_i2c_block(ledBarGet_cmd, no_bytes = 2)
|
||||||
|
return block[0] ^ (block[1] << 8)
|
||||||
|
|
||||||
|
|
||||||
|
# Grove 4 Digit Display - initialise
|
||||||
|
def fourDigit_init(pin):
|
||||||
|
write_i2c_block(fourDigitInit_cmd + [pin, unused, unused])
|
||||||
|
read_i2c_block(no_bytes = 1)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Grove 4 Digit Display - set numeric value with or without leading zeros
|
||||||
|
# value: (0-65535) or (0000-FFFF)
|
||||||
|
def fourDigit_number(pin, value, leading_zero):
|
||||||
|
# split the value into two bytes so we can render 0000-FFFF on the display
|
||||||
|
byte1 = value & 255
|
||||||
|
byte2 = value >> 8
|
||||||
|
# separate commands to overcome current 4 bytes per command limitation
|
||||||
|
if (leading_zero):
|
||||||
|
write_i2c_block(fourDigitValue_cmd + [pin, byte1, byte2])
|
||||||
|
else:
|
||||||
|
write_i2c_block(fourDigitValueZeros_cmd + [pin, byte1, byte2])
|
||||||
|
read_i2c_block(no_bytes = 1)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Grove 4 Digit Display - set brightness
|
||||||
|
# brightness: (0-7)
|
||||||
|
def fourDigit_brightness(pin, brightness):
|
||||||
|
# not actually visible until next command is executed
|
||||||
|
write_i2c_block(fourDigitBrightness_cmd + [pin, brightness, unused])
|
||||||
|
read_i2c_block(no_bytes = 1)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Grove 4 Digit Display - set individual segment (0-9,A-F)
|
||||||
|
# segment: (0-3)
|
||||||
|
# value: (0-15) or (0-F)
|
||||||
|
def fourDigit_digit(pin, segment, value):
|
||||||
|
write_i2c_block(fourDigitIndividualDigit_cmd + [pin, segment, value])
|
||||||
|
read_i2c_block(no_bytes = 1)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Grove 4 Digit Display - set 7 individual leds of a segment
|
||||||
|
# segment: (0-3)
|
||||||
|
# leds: (0-255) or (0-0xFF) one bit per led, segment 2 is special, 8th bit is the colon
|
||||||
|
def fourDigit_segment(pin, segment, leds):
|
||||||
|
write_i2c_block(fourDigitIndividualLeds_cmd + [pin, segment, leds])
|
||||||
|
read_i2c_block(no_bytes = 1)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Grove 4 Digit Display - set left and right values (0-99), with leading zeros and a colon
|
||||||
|
# left: (0-255) or (0-FF)
|
||||||
|
# right: (0-255) or (0-FF)
|
||||||
|
# colon will be lit
|
||||||
|
def fourDigit_score(pin, left, right):
|
||||||
|
write_i2c_block(fourDigitScore_cmd + [pin, left, right])
|
||||||
|
read_i2c_block(no_bytes = 1)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Grove 4 Digit Display - display analogRead value for n seconds, 4 samples per second
|
||||||
|
# analog: analog pin to read
|
||||||
|
# duration: analog read for this many seconds
|
||||||
|
def fourDigit_monitor(pin, analog, duration):
|
||||||
|
write_i2c_block(fourDigitAnalogRead_cmd + [pin, analog, duration])
|
||||||
|
read_i2c_block(no_bytes = 1)
|
||||||
|
time.sleep(duration)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Grove 4 Digit Display - turn entire display on (88:88)
|
||||||
|
def fourDigit_on(pin):
|
||||||
|
write_i2c_block(fourDigitAllOn_cmd + [pin, unused, unused])
|
||||||
|
read_i2c_block(no_bytes = 1)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Grove 4 Digit Display - turn entire display off
|
||||||
|
def fourDigit_off(pin):
|
||||||
|
write_i2c_block(fourDigitAllOff_cmd + [pin, unused, unused])
|
||||||
|
read_i2c_block(no_bytes = 1)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Grove Chainable RGB LED - store a color for later use
|
||||||
|
# red: 0-255
|
||||||
|
# green: 0-255
|
||||||
|
# blue: 0-255
|
||||||
|
def storeColor(red, green, blue):
|
||||||
|
write_i2c_block(storeColor_cmd + [red, green, blue])
|
||||||
|
read_i2c_block(no_bytes = 1)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Grove Chainable RGB LED - initialise
|
||||||
|
# numLeds: how many leds do you have in the chain
|
||||||
|
def chainableRgbLed_init(pin, numLeds):
|
||||||
|
write_i2c_block(chainableRgbLedInit_cmd + [pin, numLeds, unused])
|
||||||
|
read_i2c_block(no_bytes = 1)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Grove Chainable RGB LED - initialise and test with a simple color
|
||||||
|
# numLeds: how many leds do you have in the chain
|
||||||
|
# testColor: (0-7) 3 bits in total - a bit for red, green and blue, eg. 0x04 == 0b100 (0bRGB) == rgb(255, 0, 0) == #FF0000 == red
|
||||||
|
# ie. 0 black, 1 blue, 2 green, 3 cyan, 4 red, 5 magenta, 6 yellow, 7 white
|
||||||
|
def chainableRgbLed_test(pin, numLeds, testColor):
|
||||||
|
write_i2c_block(chainableRgbLedTest_cmd + [pin, numLeds, testColor])
|
||||||
|
read_i2c_block(no_bytes = 1)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Grove Chainable RGB LED - set one or more leds to the stored color by pattern
|
||||||
|
# pattern: (0-3) 0 = this led only, 1 all leds except this led, 2 this led and all leds inwards, 3 this led and all leds outwards
|
||||||
|
# whichLed: index of led you wish to set counting outwards from the GrovePi, 0 = led closest to the GrovePi
|
||||||
|
def chainableRgbLed_pattern(pin, pattern, whichLed):
|
||||||
|
write_i2c_block(chainableRgbLedSetPattern_cmd + [pin, pattern, whichLed])
|
||||||
|
read_i2c_block(no_bytes = 1)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Grove Chainable RGB LED - set one or more leds to the stored color by modulo
|
||||||
|
# offset: index of led you wish to start at, 0 = led closest to the GrovePi, counting outwards
|
||||||
|
# divisor: when 1 (default) sets stored color on all leds >= offset, when 2 sets every 2nd led >= offset and so on
|
||||||
|
def chainableRgbLed_modulo(pin, offset, divisor):
|
||||||
|
write_i2c_block(chainableRgbLedSetModulo_cmd + [pin, offset, divisor])
|
||||||
|
read_i2c_block(no_bytes = 1)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Grove Chainable RGB LED - sets leds similar to a bar graph, reversible
|
||||||
|
# level: (0-10) the number of leds you wish to set to the stored color
|
||||||
|
# reversible (0-1) when 0 counting outwards from GrovePi, 0 = led closest to the GrovePi, otherwise counting inwards
|
||||||
|
def chainableRgbLed_setLevel(pin, level, reverse):
|
||||||
|
write_i2c_block(chainableRgbLedSetLevel_cmd + [pin, level, reverse])
|
||||||
|
read_i2c_block(no_bytes = 1)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
def set_pin_interrupt(pin, ftype, interrupt_mode, period):
|
||||||
|
'''
|
||||||
|
Attach an interrupt to a pin.
|
||||||
|
|
||||||
|
pin - D2-D8 pins
|
||||||
|
ftype - 0 for COUNT_CHANGES, 1 for COUNT_LOW_DURATION
|
||||||
|
interrupt_mode - 1 for CHANGE, 2 for FALLING, 3 for RISING
|
||||||
|
period - as measured in ms (max 65535 ms)
|
||||||
|
'''
|
||||||
|
period_high = period >> 8
|
||||||
|
period_low = period & 0xff
|
||||||
|
combined_params = (pin & 0x0f) + ((ftype & 0x03) << 4) + ((interrupt_mode & 0x03) << 6)
|
||||||
|
write_i2c_block(isr_set_cmd + [combined_params, period_high, period_low])
|
||||||
|
read_i2c_block(no_bytes = 1)
|
||||||
|
|
||||||
|
def unset_pin_interrupt(pin):
|
||||||
|
'''
|
||||||
|
Detach an interrupt from a pin.
|
||||||
|
|
||||||
|
pin - D2-D8 pins
|
||||||
|
'''
|
||||||
|
write_i2c_block(isr_unset_cmd + [pin, unused, unused])
|
||||||
|
read_i2c_block(no_bytes = 1)
|
||||||
|
|
||||||
|
def unset_all_interrupts():
|
||||||
|
'''
|
||||||
|
Detach all attached interrupts from all D2-D8 pins.
|
||||||
|
|
||||||
|
pin - D2-D8 pins
|
||||||
|
'''
|
||||||
|
write_i2c_block(isr_clear_cmd + 3 * [unused])
|
||||||
|
read_i2c_block(no_bytes = 1)
|
||||||
|
|
||||||
|
def is_interrupt_active(pin):
|
||||||
|
write_i2c_block(isr_active_cmd + [pin, unused, unused])
|
||||||
|
data = read_identified_i2c_block(isr_active_cmd, no_bytes = 2)
|
||||||
|
value = data[1] >> pin
|
||||||
|
return value != 0
|
||||||
|
|
||||||
|
def get_active_interrupts():
|
||||||
|
'''
|
||||||
|
Get list of attached interrupts for a given pin or all of them.
|
||||||
|
|
||||||
|
pin - D2-D8 pins; if it's 255 return the state of all pins
|
||||||
|
'''
|
||||||
|
pin = 255
|
||||||
|
write_i2c_block(isr_active_cmd + [pin, unused, unused])
|
||||||
|
data = read_identified_i2c_block(isr_active_cmd, no_bytes = 2)
|
||||||
|
value = data[0] + (data[1] << 8)
|
||||||
|
active_interrupts = [i for i in range(2 * 8) if ((value >> i) & 0x01)]
|
||||||
|
return active_interrupts
|
||||||
|
|
||||||
|
def read_interrupt_state(pin):
|
||||||
|
'''
|
||||||
|
Read number of pulses/changes on given port that occurred within a time period.
|
||||||
|
|
||||||
|
pin - D2-D8 pins
|
||||||
|
'''
|
||||||
|
write_i2c_block(isr_read_cmd + [pin, unused, unused])
|
||||||
|
data = read_identified_i2c_block(isr_read_cmd, no_bytes = 4)
|
||||||
|
value = data[0] + (data[1] << 8) + (data[2] << 16) + (data[3] << 24)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def dust_sensor_en(pin = 2, period = 30000):
|
||||||
|
set_pin_interrupt(pin, ftype=COUNT_LOW_DURATION, interrupt_mode=CHANGE, period=period)
|
||||||
|
|
||||||
|
def dust_sensor_dis(pin = 2):
|
||||||
|
unset_pin_interrupt(pin)
|
||||||
|
|
||||||
|
def dust_sensor_read(pin = 2, period = 30000):
|
||||||
|
'''
|
||||||
|
By default, the sample rate is set to 1 at every 30 seconds and this
|
||||||
|
function was written only for that interval.
|
||||||
|
|
||||||
|
If you wish to use a different
|
||||||
|
interval, then use dust_sensor_read_more function. To set a
|
||||||
|
different interval, use set_dust_sensor_interval function.
|
||||||
|
'''
|
||||||
|
lpo = read_interrupt_state(pin)
|
||||||
|
percentage = 100.0 * lpo / period
|
||||||
|
concentration = 1.1 * percentage ** 3 - 3.8 * percentage ** 2 + 520 * percentage + 0.62
|
||||||
|
|
||||||
|
return lpo, percentage, concentration
|
||||||
|
|
||||||
|
def encoder_en(pin = 2, steps = 32):
|
||||||
|
write_i2c_block(encoder_en_cmd + [pin, steps, unused])
|
||||||
|
read_i2c_block(no_bytes = 1)
|
||||||
|
|
||||||
|
def encoder_dis(pin = 2):
|
||||||
|
write_i2c_block(encoder_dis_cmd + [pin, unused, unused])
|
||||||
|
read_i2c_block(no_bytes = 1)
|
||||||
|
|
||||||
|
def encoderRead(pin = 2):
|
||||||
|
write_i2c_block(encoder_read_cmd + [pin, unused, unused])
|
||||||
|
data = read_identified_i2c_block(encoder_read_cmd, no_bytes = 4)
|
||||||
|
value = data[0] + (data[1] << 8) + (data[2] << 16) + (data[3] << 24)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def flowEnable(pin = 2, period = 2000):
|
||||||
|
set_pin_interrupt(pin, ftype=COUNT_CHANGES, interrupt_mode=RISING, period=period)
|
||||||
|
|
||||||
|
def flowDisable(pin = 2):
|
||||||
|
unset_pin_interrupt(pin)
|
||||||
|
|
||||||
|
def flowRead(pin = 2):
|
||||||
|
val = read_interrupt_state(pin)
|
||||||
|
return val
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("library supports this fw versions: " +
|
||||||
|
" ".join('{}'.format(k[1]) for k in enumerate(works_with_firmware)))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import threading
|
||||||
|
# Dedicated lock for I2C bus access (used by GrovePi sensors)
|
||||||
|
grove_lock = threading.Lock()
|
||||||
|
# Dedicated lock for UART/Serial port access
|
||||||
|
serial_lock = threading.Lock()
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import grovepi
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
from sensors.lock import grove_lock
|
||||||
|
|
||||||
|
# Connect the Grove Temperature & Humidity Sensor Pro to digital port D3
|
||||||
|
# This example uses the blue colored sensor.
|
||||||
|
# SIG,NC,VCC,GND
|
||||||
|
sensor = 3 # The Sensor goes on digital port 3.
|
||||||
|
|
||||||
|
# temp_humidity_sensor_type
|
||||||
|
# Grove Base Kit comes with the blue sensor.
|
||||||
|
blue = 0 # The Blue colored sensor.
|
||||||
|
white = 1 # The White colored sensor.
|
||||||
|
|
||||||
|
def get_temperature_and_humidity():
|
||||||
|
with grove_lock:
|
||||||
|
[temp,humidity] = grovepi.dht(sensor,blue)
|
||||||
|
if math.isnan(temp) == False and math.isnan(humidity) == False:
|
||||||
|
return temp, humidity
|
||||||
|
else:
|
||||||
|
print("Error reading from DHT sensor")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def get_temperature_and_humidity_with_retry(max_retries=3):
|
||||||
|
for _ in range(max_retries): # Try up to max_retries times
|
||||||
|
temp, humidity = get_temperature_and_humidity()
|
||||||
|
if temp is not None and humidity is not None:
|
||||||
|
return temp, humidity
|
||||||
|
time.sleep(1) # Wait a bit before retrying
|
||||||
|
return None, None
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import grovepi
|
||||||
|
from sensors.lock import grove_lock
|
||||||
|
from shared import config
|
||||||
|
|
||||||
|
# Connect the Grove Ultrasonic Ranger to digital port D4
|
||||||
|
# SIG,NC,VCC,GND
|
||||||
|
ULTRASONIC_RANGER_PORT = 4
|
||||||
|
|
||||||
|
def read_ultrasonic_ranger(ultrasonic_ranger=ULTRASONIC_RANGER_PORT):
|
||||||
|
if not grove_lock.acquire(timeout=1.0):
|
||||||
|
print("Ultrasonic: Lock acquisition timed out")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return grovepi.ultrasonicRead(ultrasonic_ranger)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
grove_lock.release()
|
||||||
|
|
||||||
|
def get_dish_height():
|
||||||
|
"""Returns the height of the dish in centimeters."""
|
||||||
|
distance = read_ultrasonic_ranger()
|
||||||
|
if distance is not None:
|
||||||
|
# Assuming the ultrasonic sensor is mounted at a fixed height above the dish
|
||||||
|
# and pointing downwards, we can calculate the height of the dish.
|
||||||
|
# For example, if the sensor is 30 cm above the dish when it's empty:
|
||||||
|
# cm
|
||||||
|
dish_height = config.COOKING_COMPARTMENT_HEIGHT - distance
|
||||||
|
return max(dish_height, 0) # Ensure height is not negative
|
||||||
|
else:
|
||||||
|
return None
|
||||||
@@ -13,5 +13,8 @@ ExecStart=/bin/sh /home/pi/SmartWave/orchestrateur/launch.sh /home/pi/SmartWave/
|
|||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
||||||
|
TimeoutStopSec=5s
|
||||||
|
KillMode=mixed
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
@@ -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']
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
paho-mqtt>=1.6,<3
|
|
||||||
pyserial>=3.5,<4
|
|
||||||
@@ -3,6 +3,18 @@
|
|||||||
|
|
||||||
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.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
|
||||||
@@ -15,3 +27,7 @@ def get_database(*args, **kwargs):
|
|||||||
def get_mqtt_client(*args, **kwargs):
|
def get_mqtt_client(*args, **kwargs):
|
||||||
from .mqtt import BrokerClient
|
from .mqtt import BrokerClient
|
||||||
return BrokerClient(*args, **kwargs)
|
return BrokerClient(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_uart(*args, **kwargs):
|
||||||
|
from .uart_comm import SafeUART
|
||||||
|
return SafeUART(*args, **kwargs)
|
||||||
+11
-4
@@ -1,11 +1,18 @@
|
|||||||
|
DEBUG=True
|
||||||
|
|
||||||
# LoRa
|
# LoRa
|
||||||
HEARTBEAT_INTERVAL = 10
|
LORA_HEARTBEAT_INTERVAL = 30
|
||||||
|
|
||||||
# MQTT
|
# MQTT
|
||||||
MQTT_BROKER_HOST = "192.168.50.1"
|
MQTT_BROKER_HOST = "192.168.50.1"
|
||||||
MQTT_TOPIC = b"smartwave/demo"
|
MQTT_TOPIC_HELLO = b"smartwave/hello"
|
||||||
|
MQTT_TOPIC_SENSOR = b"smartwave/sensor"
|
||||||
|
MQTT_TOPIC_COOKING = b"smartwave/cooking"
|
||||||
MQTT_KEEPALIVE = 30
|
MQTT_KEEPALIVE = 30
|
||||||
USE_TLS = True
|
USE_TLS = True
|
||||||
MQTT_QOS = 2
|
MQTT_QOS = 1
|
||||||
|
# Long because messages are stored into the broker and will be sent when the orchestrator is back online.
|
||||||
|
MQTT_HELLO_INTERVAL = 30
|
||||||
|
|
||||||
|
# Microwave Model
|
||||||
|
COOKING_COMPARTMENT_HEIGHT = 30 # cm
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
class CookingState:
|
||||||
|
TEMPERATURE_TOLERANCE = 1.0
|
||||||
|
MIN_SIGNIFICANT_HEATING_RATE = 0.05 # °C/s threshold to consider valid heating
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Moving average filter for heating rate (°C / sec)
|
||||||
|
self._smoothed_heating_rate = 0.0
|
||||||
|
|
||||||
|
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()
|
||||||
|
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.paused = False
|
||||||
|
|
||||||
|
def toggle_pause(self):
|
||||||
|
if self.paused:
|
||||||
|
self.unpause()
|
||||||
|
else:
|
||||||
|
self.pause()
|
||||||
|
if self.on_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()
|
||||||
|
|
||||||
|
# 1. Base timer remaining based on standard cook time
|
||||||
|
timer_remaining = max(0.0, float(self.cook_time) - elapsed_time)
|
||||||
|
|
||||||
|
# If temperature is unavailable or already met target, rely on standard timer
|
||||||
|
if self.current_dish_temp is None or self.current_dish_temp >= (self.target_temp - self.TEMPERATURE_TOLERANCE):
|
||||||
|
return timer_remaining
|
||||||
|
|
||||||
|
# 2. Prevent hitting 00:00 before stirring trigger:
|
||||||
|
# If we passed half cook_time and temp is far from target, extend expected base time to 1.25x cook_time
|
||||||
|
temp_progress = max(0.0, self.current_dish_temp) / max(1.0, self.target_temp)
|
||||||
|
if elapsed_time > (self.cook_time * 0.5) and temp_progress < 0.8:
|
||||||
|
adjusted_cook_time = self.cook_time * 1.25
|
||||||
|
timer_remaining = max(0.0, adjusted_cook_time - elapsed_time)
|
||||||
|
|
||||||
|
# 3. Estimate using heating rate
|
||||||
|
heating_rate = self._estimate_heating_rate()
|
||||||
|
temp_needed = self.target_temp - self.current_dish_temp
|
||||||
|
|
||||||
|
if heating_rate > 0.01:
|
||||||
|
rate_based_remaining = temp_needed / heating_rate
|
||||||
|
else:
|
||||||
|
# If flat/slow, project remaining time based on remaining missing temperature fraction
|
||||||
|
temp_ratio = max(0.1, temp_needed / self.target_temp)
|
||||||
|
rate_based_remaining = max(timer_remaining, self.cook_time * temp_ratio * 1.25)
|
||||||
|
|
||||||
|
# 4. Strict dynamic cap: Never exceed maximum possible execution window (2.0x cook_time total)
|
||||||
|
max_possible_remaining = max(0.0, (self.cook_time * 2.0) - elapsed_time)
|
||||||
|
bounded_remaining = min(rate_based_remaining, max_possible_remaining)
|
||||||
|
|
||||||
|
# Return the larger of the adjusted timer or the bounded prediction
|
||||||
|
return max(timer_remaining, bounded_remaining)
|
||||||
|
|
||||||
|
def _estimate_heating_rate(self) -> float:
|
||||||
|
"""Calculates heating rate in °C/sec over time interval."""
|
||||||
|
if self._last_temperature_sample is None or self.current_dish_temp is None:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
last_time, last_temp = self._last_temperature_sample
|
||||||
|
now = time.time()
|
||||||
|
delta_time = now - last_time
|
||||||
|
|
||||||
|
if delta_time < 0.8: # Skip micro-ticks
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
delta_temp = self.current_dish_temp - last_temp
|
||||||
|
if delta_temp <= 0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
return delta_temp / delta_time
|
||||||
|
|
||||||
|
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 _update_heating_rate(self):
|
||||||
|
"""Calculates instantaneous rate and updates the Exponential Moving Average."""
|
||||||
|
now = time.time()
|
||||||
|
if self._last_temperature_sample is None:
|
||||||
|
self._last_temperature_sample = (now, self.current_dish_temp)
|
||||||
|
return
|
||||||
|
|
||||||
|
last_time, last_temp = self._last_temperature_sample
|
||||||
|
delta_time = now - last_time
|
||||||
|
|
||||||
|
if delta_time <= 0.5 or self.current_dish_temp is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
instant_rate = (self.current_dish_temp - last_temp) / delta_time
|
||||||
|
self._last_temperature_sample = (now, self.current_dish_temp)
|
||||||
|
|
||||||
|
# Exponential Moving Average (EMA) - alpha smooths out sensor noise
|
||||||
|
alpha = 0.2
|
||||||
|
self._smoothed_heating_rate = (alpha * instant_rate) + ((1.0 - alpha) * self._smoothed_heating_rate)
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
if self.current_dish_temp is not None:
|
||||||
|
self._update_heating_rate()
|
||||||
|
|
||||||
|
if elapsed_time < (self.cook_time / 2.0) and self.current_dish_temp >= self.target_temp and (self._paused_duration is None or self._paused_duration < 5):
|
||||||
|
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 is None or self._paused_duration < 5):
|
||||||
|
self.state = CookingStates.STIRRING_REQUIRED
|
||||||
|
self.pause()
|
||||||
|
elif self._pause_started_at is not None and (self._pause_started_at + self._paused_duration) < (now - (self.cook_time * 0.75)):
|
||||||
|
self.state = CookingStates.DONE
|
||||||
|
|
||||||
|
self.estimated_remaining_time = self.get_remaining_time_estimation()
|
||||||
|
|
||||||
|
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"
|
||||||
+96
-63
@@ -5,98 +5,131 @@ same code can run on CPython (`sqlite3`) and MicroPython (`sqlite3` or
|
|||||||
`usqlite`, depending on the port).
|
`usqlite`, depending on the port).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from shared.logging import log
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import sqlite3 as _sqlite
|
import sqlite3 as _sqlite
|
||||||
DRIVER_NAME = "sqlite3"
|
DRIVER_NAME = "sqlite3"
|
||||||
except ImportError:
|
except ImportError:
|
||||||
try:
|
try:
|
||||||
import usqlite as _sqlite
|
import usqlite as _sqlite
|
||||||
DRIVER_NAME = "usqlite"
|
DRIVER_NAME = "usqlite"
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
raise ImportError("No sqlite driver found. Expected sqlite3 or usqlite.") from exc
|
log("[DB Error] No sqlite driver found. Expected sqlite3 or usqlite.")
|
||||||
|
raise ImportError("No sqlite driver found. Expected sqlite3 or usqlite.") from exc
|
||||||
|
|
||||||
|
|
||||||
def _connect(database_path, **connect_kwargs):
|
def _connect(database_path, **connect_kwargs):
|
||||||
if connect_kwargs:
|
try:
|
||||||
try:
|
if connect_kwargs:
|
||||||
return _sqlite.connect(database_path, **connect_kwargs)
|
try:
|
||||||
except TypeError:
|
return _sqlite.connect(database_path, **connect_kwargs)
|
||||||
pass
|
except TypeError:
|
||||||
return _sqlite.connect(database_path)
|
pass
|
||||||
|
return _sqlite.connect(database_path)
|
||||||
|
except Exception as e:
|
||||||
|
log(f"[DB Error] Driver connect failed for '{database_path}': {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
class Database:
|
class Database:
|
||||||
"""Lightweight connection wrapper with a consistent API."""
|
"""Lightweight connection wrapper with logging and consistent API."""
|
||||||
|
|
||||||
def __init__(self, database_path, **connect_kwargs):
|
def __init__(self, database_path, **connect_kwargs):
|
||||||
self._database_path = database_path
|
self._database_path = database_path
|
||||||
self._connect_kwargs = connect_kwargs
|
self._connect_kwargs = connect_kwargs
|
||||||
self._connection = None
|
self._connection = None
|
||||||
|
|
||||||
def open(self):
|
def open(self):
|
||||||
if self._connection is None:
|
if self._connection is None:
|
||||||
self._connection = _connect(self._database_path, **self._connect_kwargs)
|
self._connection = _connect(self._database_path, **self._connect_kwargs)
|
||||||
return self._connection
|
return self._connection
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
if self._connection is not None:
|
if self._connection is not None:
|
||||||
self._connection.close()
|
try:
|
||||||
self._connection = None
|
self._connection.close()
|
||||||
|
except Exception as e:
|
||||||
|
log(f"[DB Error] Failed to close database '{self._database_path}': {e}")
|
||||||
|
finally:
|
||||||
|
self._connection = None
|
||||||
|
|
||||||
def commit(self):
|
def commit(self):
|
||||||
connection = self.open()
|
connection = self.open()
|
||||||
if hasattr(connection, "commit"):
|
if hasattr(connection, "commit"):
|
||||||
connection.commit()
|
try:
|
||||||
|
connection.commit()
|
||||||
|
except Exception as e:
|
||||||
|
log(f"[DB Error] Commit failed on '{self._database_path}': {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
def cursor(self):
|
def cursor(self):
|
||||||
return self.open().cursor()
|
return self.open().cursor()
|
||||||
|
|
||||||
def execute(self, sql, params=None):
|
def execute(self, sql, params=None):
|
||||||
cursor = self.cursor()
|
cursor = self.cursor()
|
||||||
if params is None:
|
try:
|
||||||
cursor.execute(sql)
|
if params is None:
|
||||||
else:
|
cursor.execute(sql)
|
||||||
cursor.execute(sql, params)
|
else:
|
||||||
return cursor
|
cursor.execute(sql, params)
|
||||||
|
return cursor
|
||||||
|
except Exception as e:
|
||||||
|
log(f"[DB Error] Query failed on '{self._database_path}' | SQL: {sql} | Params: {params} | Error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
def executemany(self, sql, params_list):
|
def executemany(self, sql, params_list):
|
||||||
cursor = self.cursor()
|
cursor = self.cursor()
|
||||||
cursor.executemany(sql, params_list)
|
try:
|
||||||
return cursor
|
cursor.executemany(sql, params_list)
|
||||||
|
return cursor
|
||||||
|
except Exception as e:
|
||||||
|
log(f"[DB Error] Executemany failed on '{self._database_path}' | SQL: {sql} | Error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
def fetchone(self, sql, params=None):
|
def fetchone(self, sql, params=None):
|
||||||
return self.execute(sql, params).fetchone()
|
return self.execute(sql, params).fetchone()
|
||||||
|
|
||||||
def fetchall(self, sql, params=None):
|
def fetchall(self, sql, params=None):
|
||||||
return self.execute(sql, params).fetchall()
|
return self.execute(sql, params).fetchall()
|
||||||
|
|
||||||
def executescript(self, script):
|
def executescript(self, script):
|
||||||
connection = self.open()
|
connection = self.open()
|
||||||
if hasattr(connection, "executescript"):
|
if hasattr(connection, "executescript"):
|
||||||
return connection.executescript(script)
|
try:
|
||||||
raise NotImplementedError("executescript is not available on this sqlite backend")
|
return connection.executescript(script)
|
||||||
|
except Exception as e:
|
||||||
|
log(f"[DB Error] Executescript failed on '{self._database_path}': {e}")
|
||||||
|
raise
|
||||||
|
log("[DB Error] executescript is not available on this sqlite backend")
|
||||||
|
raise NotImplementedError("executescript is not available on this sqlite backend")
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
self.open()
|
self.open()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc, traceback):
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
if exc_type is None:
|
if exc_type is None:
|
||||||
self.commit()
|
self.commit()
|
||||||
self.close()
|
else:
|
||||||
|
log(f"[DB Error] Context exited with exception on '{self._database_path}': {exc_val}")
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
def connect(database_path, **connect_kwargs):
|
def connect(database_path, **connect_kwargs):
|
||||||
return Database(database_path, **connect_kwargs)
|
return Database(database_path, **connect_kwargs)
|
||||||
|
|
||||||
|
|
||||||
def execute(database_path, sql, params=None, **connect_kwargs):
|
def execute(database_path, sql, params=None, **connect_kwargs):
|
||||||
return connect(database_path, **connect_kwargs).execute(sql, params)
|
with connect(database_path, **connect_kwargs) as db_inst:
|
||||||
|
return db_inst.execute(sql, params)
|
||||||
|
|
||||||
|
|
||||||
def fetchone(database_path, sql, params=None, **connect_kwargs):
|
def fetchone(database_path, sql, params=None, **connect_kwargs):
|
||||||
return connect(database_path, **connect_kwargs).fetchone(sql, params)
|
with connect(database_path, **connect_kwargs) as db_inst:
|
||||||
|
return db_inst.fetchone(sql, params)
|
||||||
|
|
||||||
|
|
||||||
def fetchall(database_path, sql, params=None, **connect_kwargs):
|
def fetchall(database_path, sql, params=None, **connect_kwargs):
|
||||||
return connect(database_path, **connect_kwargs).fetchall(sql, params)
|
with connect(database_path, **connect_kwargs) as db_inst:
|
||||||
|
return db_inst.fetchall(sql, params)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from shared.config import DEBUG
|
||||||
|
|
||||||
|
def log(message):
|
||||||
|
"""Log a message to the console if DEBUG is enabled."""
|
||||||
|
if DEBUG:
|
||||||
|
print(f"\n{message}")
|
||||||
+314
-70
@@ -1,5 +1,7 @@
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
import random
|
||||||
|
from shared.logging import log
|
||||||
|
|
||||||
IS_MICROPYTHON = sys.implementation.name == 'micropython'
|
IS_MICROPYTHON = sys.implementation.name == 'micropython'
|
||||||
|
|
||||||
@@ -8,81 +10,270 @@ 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(50)
|
||||||
|
else:
|
||||||
|
time.sleep(0.05) # Reduced delay to fit ESP32 RX window
|
||||||
|
|
||||||
|
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")
|
||||||
|
log(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:
|
||||||
|
log(f"[ReliableLoRa] <- Received packet with msg_id {msg_id}. Queuing ACK.")
|
||||||
|
self._send_ack(msg_id)
|
||||||
|
|
||||||
|
if msg_id in self.processed_msg_ids:
|
||||||
|
log(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=3.0):
|
||||||
|
"""Sends a payload and listens in a single continuous RX window for the ACK."""
|
||||||
|
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
|
||||||
|
|
||||||
|
log(f"\n[ReliableLoRa] === Starting send_reliable for msg_id {msg_id} ===")
|
||||||
|
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
log(f"[ReliableLoRa] Attempt {attempt + 1}/{max_retries} transmitting msg_id {msg_id}")
|
||||||
|
self.send(payload)
|
||||||
|
|
||||||
|
# 1. Open a single continuous RX window for the full timeout duration
|
||||||
|
# (Defaulted to 3.0s to account for LA66 UART + transmission time)
|
||||||
|
timeout_ms = int(ack_timeout * 1000)
|
||||||
|
packet = self.receive_packet(timeout_ms=timeout_ms)
|
||||||
|
|
||||||
|
# 2. Process incoming packet if received
|
||||||
|
if 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()
|
||||||
|
|
||||||
|
# 3. Check if matching ACK was received
|
||||||
|
if lock: lock.acquire()
|
||||||
|
try:
|
||||||
|
if msg_id in self.received_acks:
|
||||||
|
self.received_acks.remove(msg_id)
|
||||||
|
log(f"[ReliableLoRa] === ACK received for msg_id {msg_id} on attempt {attempt + 1} ===")
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
if lock: lock.release()
|
||||||
|
|
||||||
|
log(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:
|
||||||
|
# Decode to string, ignoring unprintable characters
|
||||||
try:
|
try:
|
||||||
text = payload_brute.decode('utf-8').strip('\x00 \r\n\t')
|
raw_text = data.decode('utf-8', 'ignore').strip()
|
||||||
except UnicodeError:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if text.startswith('{') or text.startswith('['):
|
# Find where the actual JSON payload starts ({ or [)
|
||||||
decoded_text = text
|
idx_brace = raw_text.find('{')
|
||||||
elif text.lower().startswith('7b') or text.lower().startswith('5b'):
|
idx_bracket = raw_text.find('[')
|
||||||
try:
|
|
||||||
decoded_text = ubinascii.unhexlify(text).decode('utf-8').strip('\x00 \r\n\t')
|
|
||||||
except Exception:
|
|
||||||
decoded_text = text
|
|
||||||
else:
|
|
||||||
decoded_text = text
|
|
||||||
|
|
||||||
try:
|
valid_indices = [i for i in (idx_brace, idx_bracket) if i != -1]
|
||||||
parsed_json = json.loads(decoded_text)
|
|
||||||
return {"group": group, "data": parsed_json, "raw": False}
|
if valid_indices:
|
||||||
except ValueError:
|
# Slice off all leading group bytes/control characters (\x02)
|
||||||
return {"group": group, "data": decoded_text, "raw": True}
|
json_str = raw_text[min(valid_indices):]
|
||||||
|
try:
|
||||||
|
parsed_json = json.loads(json_str)
|
||||||
|
return {"group": self.default_group, "data": parsed_json, "raw": False}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {"group": self.default_group, "data": raw_text, "raw": True}
|
||||||
|
|
||||||
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,12 +286,45 @@ 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
|
|
||||||
|
|
||||||
def send(self, payload):
|
# Initial configuration
|
||||||
"""Encode automatiquement la payload en HEX pour l'envoi via la clé."""
|
self.configure(freq=868.1, sf=7, bw=125)
|
||||||
|
|
||||||
|
def _send_at_cmd(self, cmd, wait_time=0.3):
|
||||||
|
"""Sends AT command, draining unread serial noise first."""
|
||||||
|
# Drain any lingering lines (like 'Rssi= -4' or incoming data)
|
||||||
|
if self.ser.in_waiting > 0:
|
||||||
|
self.ser.read_all()
|
||||||
|
|
||||||
|
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 a HEX string and transmits via 4-parameter AT+SEND."""
|
||||||
|
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)
|
||||||
|
|
||||||
@@ -108,24 +332,17 @@ else:
|
|||||||
payload = payload.encode('utf-8')
|
payload = payload.encode('utf-8')
|
||||||
|
|
||||||
hex_payload = payload.hex()
|
hex_payload = payload.hex()
|
||||||
self.ser.reset_input_buffer()
|
|
||||||
|
|
||||||
# La clé ajoute d'elle-même l'octet de groupe configuré dans ses registres
|
log(f"[RPi LoRa Serial] Transmitting HEX string: {hex_payload}")
|
||||||
cmd = f"AT+SEND=1,{hex_payload},1,3\r\n"
|
|
||||||
print(f"RPI : Envoi de la commande HEX -> AT+SEND=1,[HEX_DATA],1,3")
|
|
||||||
self.ser.write(cmd.encode('utf-8'))
|
|
||||||
|
|
||||||
time.sleep(0.2)
|
# Format: AT+SEND=<group>,<payload_string>,<confirm>,<retries>
|
||||||
response = ""
|
cmd = f"AT+SEND={group},{hex_payload},0,3"
|
||||||
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()}")
|
resp = self._send_at_cmd(cmd, wait_time=0.3)
|
||||||
|
log(f"[RPi LoRa Serial] AT+SEND response: {resp}")
|
||||||
|
|
||||||
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 +353,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')
|
||||||
@@ -181,3 +418,10 @@ def get_lora_device(port_or_pins=None):
|
|||||||
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"
|
||||||
|
TOGGLE_DEFROST = "toggle_defrost"
|
||||||
+129
-25
@@ -11,15 +11,12 @@ 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:
|
except ImportError as exc:
|
||||||
try:
|
raise ImportError("No MQTT client found. Expected paho.mqtt or umqtt.") from exc
|
||||||
from umqtt.robust import MQTTClient as _MQTTClient
|
|
||||||
BACKEND_NAME = "umqtt.robust"
|
|
||||||
IS_MICROPYTHON = True
|
|
||||||
except ImportError as exc:
|
|
||||||
raise ImportError("No MQTT client found. Expected paho.mqtt or umqtt.") from exc
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_PORT = 8884
|
DEFAULT_PORT = 8884
|
||||||
@@ -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,17 +106,25 @@ 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",
|
||||||
self.host,
|
self.host,
|
||||||
@@ -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,14 +198,63 @@ 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')
|
||||||
|
|
||||||
return client.subscribe(topic, qos=qos)
|
return client.subscribe(topic, qos=qos)
|
||||||
|
|
||||||
|
def unsubscribe(self, topic):
|
||||||
|
client = self.open()
|
||||||
|
if IS_MICROPYTHON:
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
topic_bytes = topic if isinstance(topic, bytes) else topic.encode('utf-8')
|
||||||
|
|
||||||
|
# 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")
|
||||||
|
struct.pack_into("!BH", pkt, 1, rem_len, sent_pid)
|
||||||
|
|
||||||
|
# 3. Write packet to socket
|
||||||
|
client.sock.write(pkt)
|
||||||
|
client._send_str(topic_bytes)
|
||||||
|
|
||||||
|
# 4. Wait for UNSUBACK (0xB0)
|
||||||
|
start = time.time()
|
||||||
|
while time.time() - start < 3:
|
||||||
|
op = client.wait_msg()
|
||||||
|
if op == 0xB0:
|
||||||
|
resp = bytearray(3)
|
||||||
|
read_bytes = 0
|
||||||
|
while read_bytes < 3:
|
||||||
|
chunk = client.sock.read(3 - read_bytes)
|
||||||
|
if chunk:
|
||||||
|
resp[read_bytes:read_bytes + len(chunk)] = chunk
|
||||||
|
read_bytes += len(chunk)
|
||||||
|
else:
|
||||||
|
time.sleep_ms(10)
|
||||||
|
|
||||||
|
# Compare against sent_pid instead of client.pid
|
||||||
|
resp_pid = (resp[1] << 8) | resp[2]
|
||||||
|
if resp_pid != sent_pid:
|
||||||
|
print(f"[MQTT] UNSUBACK PID mismatch (expected {sent_pid}, got {resp_pid})")
|
||||||
|
return client
|
||||||
|
|
||||||
|
return client
|
||||||
|
|
||||||
|
if isinstance(topic, bytes):
|
||||||
|
topic = topic.decode('utf-8')
|
||||||
|
|
||||||
|
return client.unsubscribe(topic)
|
||||||
|
|
||||||
def _on_micropython_message(self, topic, payload):
|
def _on_micropython_message(self, topic, payload):
|
||||||
self._store_message(topic, payload, None, False)
|
self._store_message(topic, payload, None, False)
|
||||||
|
|
||||||
@@ -187,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):
|
||||||
@@ -203,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()
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
from time import time
|
||||||
|
from shared.deviceTypes import DEVICE_TYPES
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
import ujson as json
|
||||||
|
except ImportError:
|
||||||
|
import json
|
||||||
|
|
||||||
|
def as_json(data):
|
||||||
|
"""Convert a dictionary to a JSON string."""
|
||||||
|
try:
|
||||||
|
return json.dumps(data)
|
||||||
|
except Exception as e:
|
||||||
|
print("[Payloads] Error converting to JSON:", e)
|
||||||
|
return "{}" # Return an empty JSON object on error
|
||||||
|
|
||||||
|
def mqtt_hello(id_microwave):
|
||||||
|
return as_json({
|
||||||
|
"id_microwave": id_microwave,
|
||||||
|
"type": DEVICE_TYPES["MICROWAVE"]
|
||||||
|
})
|
||||||
|
|
||||||
|
def mqtt_hello_ack(id_orchestrator, id_microwave):
|
||||||
|
return as_json({
|
||||||
|
"id_microwave": id_microwave,
|
||||||
|
"id_orchestrator": id_orchestrator
|
||||||
|
})
|
||||||
|
|
||||||
|
def mqtt_cooking_init(id_microwave):
|
||||||
|
return as_json({
|
||||||
|
"id_microwave": id_microwave
|
||||||
|
})
|
||||||
|
|
||||||
|
def mqtt_sensor_data(id_microwave, dish_temp, ambient_temp):
|
||||||
|
return as_json({
|
||||||
|
"id_microwave": id_microwave,
|
||||||
|
"dish_temp": dish_temp,
|
||||||
|
"ambient_temp": ambient_temp
|
||||||
|
})
|
||||||
|
|
||||||
|
def lora_sensor_data(dish_temp, ambient_temp):
|
||||||
|
return as_json({
|
||||||
|
"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,162 @@
|
|||||||
|
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)
|
||||||
|
# Automatically turn LED on unless color is OFF (0,0,0)
|
||||||
|
self._is_on = (self._color != self.OFF)
|
||||||
|
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
|
||||||
|
self.on() # Ensures LED remains ON steadily after blinking stops
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import _thread
|
||||||
|
from machine import UART
|
||||||
|
import time
|
||||||
|
import ujson
|
||||||
|
|
||||||
|
class SafeUART:
|
||||||
|
def __init__(self, uart_id, tx_pin, rx_pin, baudrate=115200):
|
||||||
|
# Setting timeout allows readline() to be non-blocking
|
||||||
|
self.uart = UART(uart_id, baudrate=baudrate, tx=tx_pin, rx=rx_pin, timeout=10, rxbuf=1024)
|
||||||
|
self.lock = _thread.allocate_lock()
|
||||||
|
self.rx_queue = []
|
||||||
|
|
||||||
|
_thread.stack_size(4096)
|
||||||
|
_thread.start_new_thread(self._listener_worker, ())
|
||||||
|
_thread.stack_size(0)
|
||||||
|
|
||||||
|
def _listener_worker(self):
|
||||||
|
"""Simple worker that relies on newline framing instead of manual JSON parsing."""
|
||||||
|
while True:
|
||||||
|
if self.uart.any():
|
||||||
|
with self.lock:
|
||||||
|
line = self.uart.readline()
|
||||||
|
|
||||||
|
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):
|
||||||
|
if not message.endswith('\n'):
|
||||||
|
message += '\n'
|
||||||
|
with self.lock:
|
||||||
|
self.uart.write(message.encode('utf-8'))
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
with self.lock:
|
||||||
|
return self.rx_queue.pop(0) if self.rx_queue else None
|
||||||
|
|
||||||
|
def send_as_command(self, command: 'UARTCommand'):
|
||||||
|
"""Safely sends a structured command over UART."""
|
||||||
|
json_message = command.to_json()
|
||||||
|
self.send(json_message)
|
||||||
|
|
||||||
|
def any(self):
|
||||||
|
"""Checks if any complete messages are waiting to be read."""
|
||||||
|
with self.lock:
|
||||||
|
return len(self.rx_queue) > 0
|
||||||
|
|
||||||
|
def read_as_command(self) -> 'UARTCommand | None':
|
||||||
|
"""Attempts to read the oldest unread string and parse it as a UARTCommand. Returns None if empty or invalid."""
|
||||||
|
raw_message = self.read()
|
||||||
|
if raw_message is not None:
|
||||||
|
cmd = UARTCommand.from_json(raw_message)
|
||||||
|
if cmd is None:
|
||||||
|
print("[UART] Impossible de traiter le message brut :", raw_message)
|
||||||
|
return cmd
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class UARTCommand:
|
||||||
|
"""A simple wrapper for commands sent over UART, allowing for structured data."""
|
||||||
|
def __init__(self, command_type: str, payload):
|
||||||
|
self.command_type = command_type
|
||||||
|
self.payload = payload
|
||||||
|
|
||||||
|
def to_json(self):
|
||||||
|
"""Serializes the command to a JSON string."""
|
||||||
|
return ujson.dumps({
|
||||||
|
"command_type": self.command_type,
|
||||||
|
"payload": self.payload
|
||||||
|
})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_json(json_string: str) -> 'UARTCommand | None':
|
||||||
|
"""Deserializes a JSON string into a UARTCommand object."""
|
||||||
|
try:
|
||||||
|
# Remplacement préventif si des guillemets simples sont reçus
|
||||||
|
clean_str = json_string.replace("'", '"') if "'" in json_string else json_string
|
||||||
|
data = ujson.loads(clean_str)
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return UARTCommand(data.get("command_type"), data.get("payload"))
|
||||||
|
except Exception as err:
|
||||||
|
# Affiche l'erreur exacte rencontrée par ujson (ex: syntax error)
|
||||||
|
print(f"[UARTCommand Parsing Error]: {err} -> Contenu: {json_string}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class UARTCommandType:
|
||||||
|
"""Enumeration of known UART command types."""
|
||||||
|
COOKING_PARAMS = "COOKING_PARAMS"
|
||||||
|
COOKING_STATE_UPDATE = "COOKING_STATE_UPDATE"
|
||||||
|
TEMPERATURE_REQUEST = "TEMPERATURE_REQUEST"
|
||||||
|
TEMPERATURE_RESPONSE = "TEMPERATURE_RESPONSE"
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -1,247 +0,0 @@
|
|||||||
<#
|
|
||||||
.Synopsis
|
|
||||||
Activate a Python virtual environment for the current PowerShell session.
|
|
||||||
|
|
||||||
.Description
|
|
||||||
Pushes the python executable for a virtual environment to the front of the
|
|
||||||
$Env:PATH environment variable and sets the prompt to signify that you are
|
|
||||||
in a Python virtual environment. Makes use of the command line switches as
|
|
||||||
well as the `pyvenv.cfg` file values present in the virtual environment.
|
|
||||||
|
|
||||||
.Parameter VenvDir
|
|
||||||
Path to the directory that contains the virtual environment to activate. The
|
|
||||||
default value for this is the parent of the directory that the Activate.ps1
|
|
||||||
script is located within.
|
|
||||||
|
|
||||||
.Parameter Prompt
|
|
||||||
The prompt prefix to display when this virtual environment is activated. By
|
|
||||||
default, this prompt is the name of the virtual environment folder (VenvDir)
|
|
||||||
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
|
|
||||||
|
|
||||||
.Example
|
|
||||||
Activate.ps1
|
|
||||||
Activates the Python virtual environment that contains the Activate.ps1 script.
|
|
||||||
|
|
||||||
.Example
|
|
||||||
Activate.ps1 -Verbose
|
|
||||||
Activates the Python virtual environment that contains the Activate.ps1 script,
|
|
||||||
and shows extra information about the activation as it executes.
|
|
||||||
|
|
||||||
.Example
|
|
||||||
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
|
|
||||||
Activates the Python virtual environment located in the specified location.
|
|
||||||
|
|
||||||
.Example
|
|
||||||
Activate.ps1 -Prompt "MyPython"
|
|
||||||
Activates the Python virtual environment that contains the Activate.ps1 script,
|
|
||||||
and prefixes the current prompt with the specified string (surrounded in
|
|
||||||
parentheses) while the virtual environment is active.
|
|
||||||
|
|
||||||
.Notes
|
|
||||||
On Windows, it may be required to enable this Activate.ps1 script by setting the
|
|
||||||
execution policy for the user. You can do this by issuing the following PowerShell
|
|
||||||
command:
|
|
||||||
|
|
||||||
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
|
||||||
|
|
||||||
For more information on Execution Policies:
|
|
||||||
https://go.microsoft.com/fwlink/?LinkID=135170
|
|
||||||
|
|
||||||
#>
|
|
||||||
Param(
|
|
||||||
[Parameter(Mandatory = $false)]
|
|
||||||
[String]
|
|
||||||
$VenvDir,
|
|
||||||
[Parameter(Mandatory = $false)]
|
|
||||||
[String]
|
|
||||||
$Prompt
|
|
||||||
)
|
|
||||||
|
|
||||||
<# Function declarations --------------------------------------------------- #>
|
|
||||||
|
|
||||||
<#
|
|
||||||
.Synopsis
|
|
||||||
Remove all shell session elements added by the Activate script, including the
|
|
||||||
addition of the virtual environment's Python executable from the beginning of
|
|
||||||
the PATH variable.
|
|
||||||
|
|
||||||
.Parameter NonDestructive
|
|
||||||
If present, do not remove this function from the global namespace for the
|
|
||||||
session.
|
|
||||||
|
|
||||||
#>
|
|
||||||
function global:deactivate ([switch]$NonDestructive) {
|
|
||||||
# Revert to original values
|
|
||||||
|
|
||||||
# The prior prompt:
|
|
||||||
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
|
|
||||||
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
|
|
||||||
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
|
|
||||||
}
|
|
||||||
|
|
||||||
# The prior PYTHONHOME:
|
|
||||||
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
|
|
||||||
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
|
|
||||||
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
|
|
||||||
}
|
|
||||||
|
|
||||||
# The prior PATH:
|
|
||||||
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
|
|
||||||
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
|
|
||||||
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
|
|
||||||
}
|
|
||||||
|
|
||||||
# Just remove the VIRTUAL_ENV altogether:
|
|
||||||
if (Test-Path -Path Env:VIRTUAL_ENV) {
|
|
||||||
Remove-Item -Path env:VIRTUAL_ENV
|
|
||||||
}
|
|
||||||
|
|
||||||
# Just remove VIRTUAL_ENV_PROMPT altogether.
|
|
||||||
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
|
|
||||||
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
|
|
||||||
}
|
|
||||||
|
|
||||||
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
|
|
||||||
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
|
|
||||||
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
|
|
||||||
}
|
|
||||||
|
|
||||||
# Leave deactivate function in the global namespace if requested:
|
|
||||||
if (-not $NonDestructive) {
|
|
||||||
Remove-Item -Path function:deactivate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
<#
|
|
||||||
.Description
|
|
||||||
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
|
|
||||||
given folder, and returns them in a map.
|
|
||||||
|
|
||||||
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
|
|
||||||
two strings separated by `=` (with any amount of whitespace surrounding the =)
|
|
||||||
then it is considered a `key = value` line. The left hand string is the key,
|
|
||||||
the right hand is the value.
|
|
||||||
|
|
||||||
If the value starts with a `'` or a `"` then the first and last character is
|
|
||||||
stripped from the value before being captured.
|
|
||||||
|
|
||||||
.Parameter ConfigDir
|
|
||||||
Path to the directory that contains the `pyvenv.cfg` file.
|
|
||||||
#>
|
|
||||||
function Get-PyVenvConfig(
|
|
||||||
[String]
|
|
||||||
$ConfigDir
|
|
||||||
) {
|
|
||||||
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
|
|
||||||
|
|
||||||
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
|
|
||||||
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
|
|
||||||
|
|
||||||
# An empty map will be returned if no config file is found.
|
|
||||||
$pyvenvConfig = @{ }
|
|
||||||
|
|
||||||
if ($pyvenvConfigPath) {
|
|
||||||
|
|
||||||
Write-Verbose "File exists, parse `key = value` lines"
|
|
||||||
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
|
|
||||||
|
|
||||||
$pyvenvConfigContent | ForEach-Object {
|
|
||||||
$keyval = $PSItem -split "\s*=\s*", 2
|
|
||||||
if ($keyval[0] -and $keyval[1]) {
|
|
||||||
$val = $keyval[1]
|
|
||||||
|
|
||||||
# Remove extraneous quotations around a string value.
|
|
||||||
if ("'""".Contains($val.Substring(0, 1))) {
|
|
||||||
$val = $val.Substring(1, $val.Length - 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
$pyvenvConfig[$keyval[0]] = $val
|
|
||||||
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $pyvenvConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
<# Begin Activate script --------------------------------------------------- #>
|
|
||||||
|
|
||||||
# Determine the containing directory of this script
|
|
||||||
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
|
||||||
$VenvExecDir = Get-Item -Path $VenvExecPath
|
|
||||||
|
|
||||||
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
|
|
||||||
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
|
|
||||||
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
|
|
||||||
|
|
||||||
# Set values required in priority: CmdLine, ConfigFile, Default
|
|
||||||
# First, get the location of the virtual environment, it might not be
|
|
||||||
# VenvExecDir if specified on the command line.
|
|
||||||
if ($VenvDir) {
|
|
||||||
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
|
|
||||||
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
|
|
||||||
Write-Verbose "VenvDir=$VenvDir"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Next, read the `pyvenv.cfg` file to determine any required value such
|
|
||||||
# as `prompt`.
|
|
||||||
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
|
|
||||||
|
|
||||||
# Next, set the prompt from the command line, or the config file, or
|
|
||||||
# just use the name of the virtual environment folder.
|
|
||||||
if ($Prompt) {
|
|
||||||
Write-Verbose "Prompt specified as argument, using '$Prompt'"
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
|
|
||||||
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
|
|
||||||
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
|
|
||||||
$Prompt = $pyvenvCfg['prompt'];
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
|
|
||||||
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
|
|
||||||
$Prompt = Split-Path -Path $venvDir -Leaf
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Verbose "Prompt = '$Prompt'"
|
|
||||||
Write-Verbose "VenvDir='$VenvDir'"
|
|
||||||
|
|
||||||
# Deactivate any currently active virtual environment, but leave the
|
|
||||||
# deactivate function in place.
|
|
||||||
deactivate -nondestructive
|
|
||||||
|
|
||||||
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
|
|
||||||
# that there is an activated venv.
|
|
||||||
$env:VIRTUAL_ENV = $VenvDir
|
|
||||||
|
|
||||||
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
|
|
||||||
|
|
||||||
Write-Verbose "Setting prompt to '$Prompt'"
|
|
||||||
|
|
||||||
# Set the prompt to include the env name
|
|
||||||
# Make sure _OLD_VIRTUAL_PROMPT is global
|
|
||||||
function global:_OLD_VIRTUAL_PROMPT { "" }
|
|
||||||
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
|
|
||||||
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
|
|
||||||
|
|
||||||
function global:prompt {
|
|
||||||
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
|
|
||||||
_OLD_VIRTUAL_PROMPT
|
|
||||||
}
|
|
||||||
$env:VIRTUAL_ENV_PROMPT = $Prompt
|
|
||||||
}
|
|
||||||
|
|
||||||
# Clear PYTHONHOME
|
|
||||||
if (Test-Path -Path Env:PYTHONHOME) {
|
|
||||||
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
|
|
||||||
Remove-Item -Path Env:PYTHONHOME
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add the venv to the PATH
|
|
||||||
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
|
|
||||||
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# This file must be used with "source bin/activate" *from bash*
|
|
||||||
# You cannot run it directly
|
|
||||||
|
|
||||||
deactivate () {
|
|
||||||
# reset old environment variables
|
|
||||||
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
|
|
||||||
PATH="${_OLD_VIRTUAL_PATH:-}"
|
|
||||||
export PATH
|
|
||||||
unset _OLD_VIRTUAL_PATH
|
|
||||||
fi
|
|
||||||
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
|
|
||||||
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
|
|
||||||
export PYTHONHOME
|
|
||||||
unset _OLD_VIRTUAL_PYTHONHOME
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Call hash to forget past commands. Without forgetting
|
|
||||||
# past commands the $PATH changes we made may not be respected
|
|
||||||
hash -r 2> /dev/null
|
|
||||||
|
|
||||||
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
|
|
||||||
PS1="${_OLD_VIRTUAL_PS1:-}"
|
|
||||||
export PS1
|
|
||||||
unset _OLD_VIRTUAL_PS1
|
|
||||||
fi
|
|
||||||
|
|
||||||
unset VIRTUAL_ENV
|
|
||||||
unset VIRTUAL_ENV_PROMPT
|
|
||||||
if [ ! "${1:-}" = "nondestructive" ] ; then
|
|
||||||
# Self destruct!
|
|
||||||
unset -f deactivate
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# unset irrelevant variables
|
|
||||||
deactivate nondestructive
|
|
||||||
|
|
||||||
# on Windows, a path can contain colons and backslashes and has to be converted:
|
|
||||||
if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then
|
|
||||||
# transform D:\path\to\venv to /d/path/to/venv on MSYS
|
|
||||||
# and to /cygdrive/d/path/to/venv on Cygwin
|
|
||||||
export VIRTUAL_ENV=$(cygpath /home/ninluc/Documents/school/IoT/smartWave/venv)
|
|
||||||
else
|
|
||||||
# use the path as-is
|
|
||||||
export VIRTUAL_ENV=/home/ninluc/Documents/school/IoT/smartWave/venv
|
|
||||||
fi
|
|
||||||
|
|
||||||
_OLD_VIRTUAL_PATH="$PATH"
|
|
||||||
PATH="$VIRTUAL_ENV/"bin":$PATH"
|
|
||||||
export PATH
|
|
||||||
|
|
||||||
# unset PYTHONHOME if set
|
|
||||||
# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
|
|
||||||
# could use `if (set -u; : $PYTHONHOME) ;` in bash
|
|
||||||
if [ -n "${PYTHONHOME:-}" ] ; then
|
|
||||||
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
|
|
||||||
unset PYTHONHOME
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
|
|
||||||
_OLD_VIRTUAL_PS1="${PS1:-}"
|
|
||||||
PS1='(venv) '"${PS1:-}"
|
|
||||||
export PS1
|
|
||||||
VIRTUAL_ENV_PROMPT='(venv) '
|
|
||||||
export VIRTUAL_ENV_PROMPT
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Call hash to forget past commands. Without forgetting
|
|
||||||
# past commands the $PATH changes we made may not be respected
|
|
||||||
hash -r 2> /dev/null
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# This file must be used with "source bin/activate.csh" *from csh*.
|
|
||||||
# You cannot run it directly.
|
|
||||||
|
|
||||||
# Created by Davide Di Blasi <davidedb@gmail.com>.
|
|
||||||
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
|
|
||||||
|
|
||||||
alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate'
|
|
||||||
|
|
||||||
# Unset irrelevant variables.
|
|
||||||
deactivate nondestructive
|
|
||||||
|
|
||||||
setenv VIRTUAL_ENV /home/ninluc/Documents/school/IoT/smartWave/venv
|
|
||||||
|
|
||||||
set _OLD_VIRTUAL_PATH="$PATH"
|
|
||||||
setenv PATH "$VIRTUAL_ENV/"bin":$PATH"
|
|
||||||
|
|
||||||
|
|
||||||
set _OLD_VIRTUAL_PROMPT="$prompt"
|
|
||||||
|
|
||||||
if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
|
|
||||||
set prompt = '(venv) '"$prompt"
|
|
||||||
setenv VIRTUAL_ENV_PROMPT '(venv) '
|
|
||||||
endif
|
|
||||||
|
|
||||||
alias pydoc python -m pydoc
|
|
||||||
|
|
||||||
rehash
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# This file must be used with "source <venv>/bin/activate.fish" *from fish*
|
|
||||||
# (https://fishshell.com/). You cannot run it directly.
|
|
||||||
|
|
||||||
function deactivate -d "Exit virtual environment and return to normal shell environment"
|
|
||||||
# reset old environment variables
|
|
||||||
if test -n "$_OLD_VIRTUAL_PATH"
|
|
||||||
set -gx PATH $_OLD_VIRTUAL_PATH
|
|
||||||
set -e _OLD_VIRTUAL_PATH
|
|
||||||
end
|
|
||||||
if test -n "$_OLD_VIRTUAL_PYTHONHOME"
|
|
||||||
set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
|
|
||||||
set -e _OLD_VIRTUAL_PYTHONHOME
|
|
||||||
end
|
|
||||||
|
|
||||||
if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
|
|
||||||
set -e _OLD_FISH_PROMPT_OVERRIDE
|
|
||||||
# prevents error when using nested fish instances (Issue #93858)
|
|
||||||
if functions -q _old_fish_prompt
|
|
||||||
functions -e fish_prompt
|
|
||||||
functions -c _old_fish_prompt fish_prompt
|
|
||||||
functions -e _old_fish_prompt
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
set -e VIRTUAL_ENV
|
|
||||||
set -e VIRTUAL_ENV_PROMPT
|
|
||||||
if test "$argv[1]" != "nondestructive"
|
|
||||||
# Self-destruct!
|
|
||||||
functions -e deactivate
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Unset irrelevant variables.
|
|
||||||
deactivate nondestructive
|
|
||||||
|
|
||||||
set -gx VIRTUAL_ENV /home/ninluc/Documents/school/IoT/smartWave/venv
|
|
||||||
|
|
||||||
set -gx _OLD_VIRTUAL_PATH $PATH
|
|
||||||
set -gx PATH "$VIRTUAL_ENV/"bin $PATH
|
|
||||||
|
|
||||||
# Unset PYTHONHOME if set.
|
|
||||||
if set -q PYTHONHOME
|
|
||||||
set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
|
|
||||||
set -e PYTHONHOME
|
|
||||||
end
|
|
||||||
|
|
||||||
if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
|
|
||||||
# fish uses a function instead of an env var to generate the prompt.
|
|
||||||
|
|
||||||
# Save the current fish_prompt function as the function _old_fish_prompt.
|
|
||||||
functions -c fish_prompt _old_fish_prompt
|
|
||||||
|
|
||||||
# With the original prompt function renamed, we can override with our own.
|
|
||||||
function fish_prompt
|
|
||||||
# Save the return status of the last command.
|
|
||||||
set -l old_status $status
|
|
||||||
|
|
||||||
# Output the venv prompt; color taken from the blue of the Python logo.
|
|
||||||
printf "%s%s%s" (set_color 4B8BBE) '(venv) ' (set_color normal)
|
|
||||||
|
|
||||||
# Restore the return status of the previous command.
|
|
||||||
echo "exit $old_status" | .
|
|
||||||
# Output the original/"old" prompt.
|
|
||||||
_old_fish_prompt
|
|
||||||
end
|
|
||||||
|
|
||||||
set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
|
|
||||||
set -gx VIRTUAL_ENV_PROMPT '(venv) '
|
|
||||||
end
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
#!/home/ninluc/Documents/school/IoT/smartWave/venv/bin/python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from flask.cli import main
|
|
||||||
if __name__ == '__main__':
|
|
||||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
#!/home/ninluc/Documents/school/IoT/smartWave/venv/bin/python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from idna.cli import main
|
|
||||||
if __name__ == '__main__':
|
|
||||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
#!/home/ninluc/Documents/school/IoT/smartWave/venv/bin/python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from mpremote.main import main
|
|
||||||
if __name__ == '__main__':
|
|
||||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
#!/home/ninluc/Documents/school/IoT/smartWave/venv/bin/python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from charset_normalizer.cli import cli_detect
|
|
||||||
if __name__ == '__main__':
|
|
||||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
|
||||||
sys.exit(cli_detect())
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
#!/home/ninluc/Documents/school/IoT/smartWave/venv/bin/python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from pip._internal.cli.main import main
|
|
||||||
if __name__ == '__main__':
|
|
||||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
#!/home/ninluc/Documents/school/IoT/smartWave/venv/bin/python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from pip._internal.cli.main import main
|
|
||||||
if __name__ == '__main__':
|
|
||||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
#!/home/ninluc/Documents/school/IoT/smartWave/venv/bin/python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from pip._internal.cli.main import main
|
|
||||||
if __name__ == '__main__':
|
|
||||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
#!/home/ninluc/Documents/school/IoT/smartWave/venv/bin/python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from serial.tools.miniterm import main
|
|
||||||
if __name__ == '__main__':
|
|
||||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
#!/home/ninluc/Documents/school/IoT/smartWave/venv/bin/python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from serial.tools.list_ports import main
|
|
||||||
if __name__ == '__main__':
|
|
||||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
python3
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/usr/bin/python3
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
python3
|
|
||||||
BIN
Binary file not shown.
@@ -1 +0,0 @@
|
|||||||
pip
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
Copyright 2010 Jason Kirtland
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a
|
|
||||||
copy of this software and associated documentation files (the
|
|
||||||
"Software"), to deal in the Software without restriction, including
|
|
||||||
without limitation the rights to use, copy, modify, merge, publish,
|
|
||||||
distribute, sublicense, and/or sell copies of the Software, and to
|
|
||||||
permit persons to whom the Software is furnished to do so, subject to
|
|
||||||
the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included
|
|
||||||
in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
||||||
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
||||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
||||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
||||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
||||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
Metadata-Version: 2.3
|
|
||||||
Name: blinker
|
|
||||||
Version: 1.9.0
|
|
||||||
Summary: Fast, simple object-to-object and broadcast signaling
|
|
||||||
Author: Jason Kirtland
|
|
||||||
Maintainer-email: Pallets Ecosystem <contact@palletsprojects.com>
|
|
||||||
Requires-Python: >=3.9
|
|
||||||
Description-Content-Type: text/markdown
|
|
||||||
Classifier: Development Status :: 5 - Production/Stable
|
|
||||||
Classifier: License :: OSI Approved :: MIT License
|
|
||||||
Classifier: Programming Language :: Python
|
|
||||||
Classifier: Typing :: Typed
|
|
||||||
Project-URL: Chat, https://discord.gg/pallets
|
|
||||||
Project-URL: Documentation, https://blinker.readthedocs.io
|
|
||||||
Project-URL: Source, https://github.com/pallets-eco/blinker/
|
|
||||||
|
|
||||||
# Blinker
|
|
||||||
|
|
||||||
Blinker provides a fast dispatching system that allows any number of
|
|
||||||
interested parties to subscribe to events, or "signals".
|
|
||||||
|
|
||||||
|
|
||||||
## Pallets Community Ecosystem
|
|
||||||
|
|
||||||
> [!IMPORTANT]\
|
|
||||||
> This project is part of the Pallets Community Ecosystem. Pallets is the open
|
|
||||||
> source organization that maintains Flask; Pallets-Eco enables community
|
|
||||||
> maintenance of related projects. If you are interested in helping maintain
|
|
||||||
> this project, please reach out on [the Pallets Discord server][discord].
|
|
||||||
>
|
|
||||||
> [discord]: https://discord.gg/pallets
|
|
||||||
|
|
||||||
|
|
||||||
## Example
|
|
||||||
|
|
||||||
Signal receivers can subscribe to specific senders or receive signals
|
|
||||||
sent by any sender.
|
|
||||||
|
|
||||||
```pycon
|
|
||||||
>>> from blinker import signal
|
|
||||||
>>> started = signal('round-started')
|
|
||||||
>>> def each(round):
|
|
||||||
... print(f"Round {round}")
|
|
||||||
...
|
|
||||||
>>> started.connect(each)
|
|
||||||
|
|
||||||
>>> def round_two(round):
|
|
||||||
... print("This is round two.")
|
|
||||||
...
|
|
||||||
>>> started.connect(round_two, sender=2)
|
|
||||||
|
|
||||||
>>> for round in range(1, 4):
|
|
||||||
... started.send(round)
|
|
||||||
...
|
|
||||||
Round 1!
|
|
||||||
Round 2!
|
|
||||||
This is round two.
|
|
||||||
Round 3!
|
|
||||||
```
|
|
||||||
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
blinker-1.9.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
|
||||||
blinker-1.9.0.dist-info/LICENSE.txt,sha256=nrc6HzhZekqhcCXSrhvjg5Ykx5XphdTw6Xac4p-spGc,1054
|
|
||||||
blinker-1.9.0.dist-info/METADATA,sha256=uIRiM8wjjbHkCtbCyTvctU37IAZk0kEe5kxAld1dvzA,1633
|
|
||||||
blinker-1.9.0.dist-info/RECORD,,
|
|
||||||
blinker-1.9.0.dist-info/WHEEL,sha256=CpUCUxeHQbRN5UGRQHYRJorO5Af-Qy_fHMctcQ8DSGI,82
|
|
||||||
blinker/__init__.py,sha256=I2EdZqpy4LyjX17Hn1yzJGWCjeLaVaPzsMgHkLfj_cQ,317
|
|
||||||
blinker/__pycache__/__init__.cpython-312.pyc,,
|
|
||||||
blinker/__pycache__/_utilities.cpython-312.pyc,,
|
|
||||||
blinker/__pycache__/base.cpython-312.pyc,,
|
|
||||||
blinker/_utilities.py,sha256=0J7eeXXTUx0Ivf8asfpx0ycVkp0Eqfqnj117x2mYX9E,1675
|
|
||||||
blinker/base.py,sha256=QpDuvXXcwJF49lUBcH5BiST46Rz9wSG7VW_p7N_027M,19132
|
|
||||||
blinker/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
Wheel-Version: 1.0
|
|
||||||
Generator: flit 3.10.1
|
|
||||||
Root-Is-Purelib: true
|
|
||||||
Tag: py3-none-any
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .base import ANY
|
|
||||||
from .base import default_namespace
|
|
||||||
from .base import NamedSignal
|
|
||||||
from .base import Namespace
|
|
||||||
from .base import Signal
|
|
||||||
from .base import signal
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"ANY",
|
|
||||||
"default_namespace",
|
|
||||||
"NamedSignal",
|
|
||||||
"Namespace",
|
|
||||||
"Signal",
|
|
||||||
"signal",
|
|
||||||
]
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,64 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import collections.abc as c
|
|
||||||
import inspect
|
|
||||||
import typing as t
|
|
||||||
from weakref import ref
|
|
||||||
from weakref import WeakMethod
|
|
||||||
|
|
||||||
T = t.TypeVar("T")
|
|
||||||
|
|
||||||
|
|
||||||
class Symbol:
|
|
||||||
"""A constant symbol, nicer than ``object()``. Repeated calls return the
|
|
||||||
same instance.
|
|
||||||
|
|
||||||
>>> Symbol('foo') is Symbol('foo')
|
|
||||||
True
|
|
||||||
>>> Symbol('foo')
|
|
||||||
foo
|
|
||||||
"""
|
|
||||||
|
|
||||||
symbols: t.ClassVar[dict[str, Symbol]] = {}
|
|
||||||
|
|
||||||
def __new__(cls, name: str) -> Symbol:
|
|
||||||
if name in cls.symbols:
|
|
||||||
return cls.symbols[name]
|
|
||||||
|
|
||||||
obj = super().__new__(cls)
|
|
||||||
cls.symbols[name] = obj
|
|
||||||
return obj
|
|
||||||
|
|
||||||
def __init__(self, name: str) -> None:
|
|
||||||
self.name = name
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
def __getnewargs__(self) -> tuple[t.Any, ...]:
|
|
||||||
return (self.name,)
|
|
||||||
|
|
||||||
|
|
||||||
def make_id(obj: object) -> c.Hashable:
|
|
||||||
"""Get a stable identifier for a receiver or sender, to be used as a dict
|
|
||||||
key or in a set.
|
|
||||||
"""
|
|
||||||
if inspect.ismethod(obj):
|
|
||||||
# The id of a bound method is not stable, but the id of the unbound
|
|
||||||
# function and instance are.
|
|
||||||
return id(obj.__func__), id(obj.__self__)
|
|
||||||
|
|
||||||
if isinstance(obj, (str, int)):
|
|
||||||
# Instances with the same value always compare equal and have the same
|
|
||||||
# hash, even if the id may change.
|
|
||||||
return obj
|
|
||||||
|
|
||||||
# Assume other types are not hashable but will always be the same instance.
|
|
||||||
return id(obj)
|
|
||||||
|
|
||||||
|
|
||||||
def make_ref(obj: T, callback: c.Callable[[ref[T]], None] | None = None) -> ref[T]:
|
|
||||||
if inspect.ismethod(obj):
|
|
||||||
return WeakMethod(obj, callback) # type: ignore[arg-type, return-value]
|
|
||||||
|
|
||||||
return ref(obj, callback)
|
|
||||||
@@ -1,512 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import collections.abc as c
|
|
||||||
import sys
|
|
||||||
import typing as t
|
|
||||||
import weakref
|
|
||||||
from collections import defaultdict
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from functools import cached_property
|
|
||||||
from inspect import iscoroutinefunction
|
|
||||||
|
|
||||||
from ._utilities import make_id
|
|
||||||
from ._utilities import make_ref
|
|
||||||
from ._utilities import Symbol
|
|
||||||
|
|
||||||
F = t.TypeVar("F", bound=c.Callable[..., t.Any])
|
|
||||||
|
|
||||||
ANY = Symbol("ANY")
|
|
||||||
"""Symbol for "any sender"."""
|
|
||||||
|
|
||||||
ANY_ID = 0
|
|
||||||
|
|
||||||
|
|
||||||
class Signal:
|
|
||||||
"""A notification emitter.
|
|
||||||
|
|
||||||
:param doc: The docstring for the signal.
|
|
||||||
"""
|
|
||||||
|
|
||||||
ANY = ANY
|
|
||||||
"""An alias for the :data:`~blinker.ANY` sender symbol."""
|
|
||||||
|
|
||||||
set_class: type[set[t.Any]] = set
|
|
||||||
"""The set class to use for tracking connected receivers and senders.
|
|
||||||
Python's ``set`` is unordered. If receivers must be dispatched in the order
|
|
||||||
they were connected, an ordered set implementation can be used.
|
|
||||||
|
|
||||||
.. versionadded:: 1.7
|
|
||||||
"""
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def receiver_connected(self) -> Signal:
|
|
||||||
"""Emitted at the end of each :meth:`connect` call.
|
|
||||||
|
|
||||||
The signal sender is the signal instance, and the :meth:`connect`
|
|
||||||
arguments are passed through: ``receiver``, ``sender``, and ``weak``.
|
|
||||||
|
|
||||||
.. versionadded:: 1.2
|
|
||||||
"""
|
|
||||||
return Signal(doc="Emitted after a receiver connects.")
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def receiver_disconnected(self) -> Signal:
|
|
||||||
"""Emitted at the end of each :meth:`disconnect` call.
|
|
||||||
|
|
||||||
The sender is the signal instance, and the :meth:`disconnect` arguments
|
|
||||||
are passed through: ``receiver`` and ``sender``.
|
|
||||||
|
|
||||||
This signal is emitted **only** when :meth:`disconnect` is called
|
|
||||||
explicitly. This signal cannot be emitted by an automatic disconnect
|
|
||||||
when a weakly referenced receiver or sender goes out of scope, as the
|
|
||||||
instance is no longer be available to be used as the sender for this
|
|
||||||
signal.
|
|
||||||
|
|
||||||
An alternative approach is available by subscribing to
|
|
||||||
:attr:`receiver_connected` and setting up a custom weakref cleanup
|
|
||||||
callback on weak receivers and senders.
|
|
||||||
|
|
||||||
.. versionadded:: 1.2
|
|
||||||
"""
|
|
||||||
return Signal(doc="Emitted after a receiver disconnects.")
|
|
||||||
|
|
||||||
def __init__(self, doc: str | None = None) -> None:
|
|
||||||
if doc:
|
|
||||||
self.__doc__ = doc
|
|
||||||
|
|
||||||
self.receivers: dict[
|
|
||||||
t.Any, weakref.ref[c.Callable[..., t.Any]] | c.Callable[..., t.Any]
|
|
||||||
] = {}
|
|
||||||
"""The map of connected receivers. Useful to quickly check if any
|
|
||||||
receivers are connected to the signal: ``if s.receivers:``. The
|
|
||||||
structure and data is not part of the public API, but checking its
|
|
||||||
boolean value is.
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.is_muted: bool = False
|
|
||||||
self._by_receiver: dict[t.Any, set[t.Any]] = defaultdict(self.set_class)
|
|
||||||
self._by_sender: dict[t.Any, set[t.Any]] = defaultdict(self.set_class)
|
|
||||||
self._weak_senders: dict[t.Any, weakref.ref[t.Any]] = {}
|
|
||||||
|
|
||||||
def connect(self, receiver: F, sender: t.Any = ANY, weak: bool = True) -> F:
|
|
||||||
"""Connect ``receiver`` to be called when the signal is sent by
|
|
||||||
``sender``.
|
|
||||||
|
|
||||||
:param receiver: The callable to call when :meth:`send` is called with
|
|
||||||
the given ``sender``, passing ``sender`` as a positional argument
|
|
||||||
along with any extra keyword arguments.
|
|
||||||
:param sender: Any object or :data:`ANY`. ``receiver`` will only be
|
|
||||||
called when :meth:`send` is called with this sender. If ``ANY``, the
|
|
||||||
receiver will be called for any sender. A receiver may be connected
|
|
||||||
to multiple senders by calling :meth:`connect` multiple times.
|
|
||||||
:param weak: Track the receiver with a :mod:`weakref`. The receiver will
|
|
||||||
be automatically disconnected when it is garbage collected. When
|
|
||||||
connecting a receiver defined within a function, set to ``False``,
|
|
||||||
otherwise it will be disconnected when the function scope ends.
|
|
||||||
"""
|
|
||||||
receiver_id = make_id(receiver)
|
|
||||||
sender_id = ANY_ID if sender is ANY else make_id(sender)
|
|
||||||
|
|
||||||
if weak:
|
|
||||||
self.receivers[receiver_id] = make_ref(
|
|
||||||
receiver, self._make_cleanup_receiver(receiver_id)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.receivers[receiver_id] = receiver
|
|
||||||
|
|
||||||
self._by_sender[sender_id].add(receiver_id)
|
|
||||||
self._by_receiver[receiver_id].add(sender_id)
|
|
||||||
|
|
||||||
if sender is not ANY and sender_id not in self._weak_senders:
|
|
||||||
# store a cleanup for weakref-able senders
|
|
||||||
try:
|
|
||||||
self._weak_senders[sender_id] = make_ref(
|
|
||||||
sender, self._make_cleanup_sender(sender_id)
|
|
||||||
)
|
|
||||||
except TypeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if "receiver_connected" in self.__dict__ and self.receiver_connected.receivers:
|
|
||||||
try:
|
|
||||||
self.receiver_connected.send(
|
|
||||||
self, receiver=receiver, sender=sender, weak=weak
|
|
||||||
)
|
|
||||||
except TypeError:
|
|
||||||
# TODO no explanation or test for this
|
|
||||||
self.disconnect(receiver, sender)
|
|
||||||
raise
|
|
||||||
|
|
||||||
return receiver
|
|
||||||
|
|
||||||
def connect_via(self, sender: t.Any, weak: bool = False) -> c.Callable[[F], F]:
|
|
||||||
"""Connect the decorated function to be called when the signal is sent
|
|
||||||
by ``sender``.
|
|
||||||
|
|
||||||
The decorated function will be called when :meth:`send` is called with
|
|
||||||
the given ``sender``, passing ``sender`` as a positional argument along
|
|
||||||
with any extra keyword arguments.
|
|
||||||
|
|
||||||
:param sender: Any object or :data:`ANY`. ``receiver`` will only be
|
|
||||||
called when :meth:`send` is called with this sender. If ``ANY``, the
|
|
||||||
receiver will be called for any sender. A receiver may be connected
|
|
||||||
to multiple senders by calling :meth:`connect` multiple times.
|
|
||||||
:param weak: Track the receiver with a :mod:`weakref`. The receiver will
|
|
||||||
be automatically disconnected when it is garbage collected. When
|
|
||||||
connecting a receiver defined within a function, set to ``False``,
|
|
||||||
otherwise it will be disconnected when the function scope ends.=
|
|
||||||
|
|
||||||
.. versionadded:: 1.1
|
|
||||||
"""
|
|
||||||
|
|
||||||
def decorator(fn: F) -> F:
|
|
||||||
self.connect(fn, sender, weak)
|
|
||||||
return fn
|
|
||||||
|
|
||||||
return decorator
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def connected_to(
|
|
||||||
self, receiver: c.Callable[..., t.Any], sender: t.Any = ANY
|
|
||||||
) -> c.Generator[None, None, None]:
|
|
||||||
"""A context manager that temporarily connects ``receiver`` to the
|
|
||||||
signal while a ``with`` block executes. When the block exits, the
|
|
||||||
receiver is disconnected. Useful for tests.
|
|
||||||
|
|
||||||
:param receiver: The callable to call when :meth:`send` is called with
|
|
||||||
the given ``sender``, passing ``sender`` as a positional argument
|
|
||||||
along with any extra keyword arguments.
|
|
||||||
:param sender: Any object or :data:`ANY`. ``receiver`` will only be
|
|
||||||
called when :meth:`send` is called with this sender. If ``ANY``, the
|
|
||||||
receiver will be called for any sender.
|
|
||||||
|
|
||||||
.. versionadded:: 1.1
|
|
||||||
"""
|
|
||||||
self.connect(receiver, sender=sender, weak=False)
|
|
||||||
|
|
||||||
try:
|
|
||||||
yield None
|
|
||||||
finally:
|
|
||||||
self.disconnect(receiver)
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def muted(self) -> c.Generator[None, None, None]:
|
|
||||||
"""A context manager that temporarily disables the signal. No receivers
|
|
||||||
will be called if the signal is sent, until the ``with`` block exits.
|
|
||||||
Useful for tests.
|
|
||||||
"""
|
|
||||||
self.is_muted = True
|
|
||||||
|
|
||||||
try:
|
|
||||||
yield None
|
|
||||||
finally:
|
|
||||||
self.is_muted = False
|
|
||||||
|
|
||||||
def send(
|
|
||||||
self,
|
|
||||||
sender: t.Any | None = None,
|
|
||||||
/,
|
|
||||||
*,
|
|
||||||
_async_wrapper: c.Callable[
|
|
||||||
[c.Callable[..., c.Coroutine[t.Any, t.Any, t.Any]]], c.Callable[..., t.Any]
|
|
||||||
]
|
|
||||||
| None = None,
|
|
||||||
**kwargs: t.Any,
|
|
||||||
) -> list[tuple[c.Callable[..., t.Any], t.Any]]:
|
|
||||||
"""Call all receivers that are connected to the given ``sender``
|
|
||||||
or :data:`ANY`. Each receiver is called with ``sender`` as a positional
|
|
||||||
argument along with any extra keyword arguments. Return a list of
|
|
||||||
``(receiver, return value)`` tuples.
|
|
||||||
|
|
||||||
The order receivers are called is undefined, but can be influenced by
|
|
||||||
setting :attr:`set_class`.
|
|
||||||
|
|
||||||
If a receiver raises an exception, that exception will propagate up.
|
|
||||||
This makes debugging straightforward, with an assumption that correctly
|
|
||||||
implemented receivers will not raise.
|
|
||||||
|
|
||||||
:param sender: Call receivers connected to this sender, in addition to
|
|
||||||
those connected to :data:`ANY`.
|
|
||||||
:param _async_wrapper: Will be called on any receivers that are async
|
|
||||||
coroutines to turn them into sync callables. For example, could run
|
|
||||||
the receiver with an event loop.
|
|
||||||
:param kwargs: Extra keyword arguments to pass to each receiver.
|
|
||||||
|
|
||||||
.. versionchanged:: 1.7
|
|
||||||
Added the ``_async_wrapper`` argument.
|
|
||||||
"""
|
|
||||||
if self.is_muted:
|
|
||||||
return []
|
|
||||||
|
|
||||||
results = []
|
|
||||||
|
|
||||||
for receiver in self.receivers_for(sender):
|
|
||||||
if iscoroutinefunction(receiver):
|
|
||||||
if _async_wrapper is None:
|
|
||||||
raise RuntimeError("Cannot send to a coroutine function.")
|
|
||||||
|
|
||||||
result = _async_wrapper(receiver)(sender, **kwargs)
|
|
||||||
else:
|
|
||||||
result = receiver(sender, **kwargs)
|
|
||||||
|
|
||||||
results.append((receiver, result))
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
async def send_async(
|
|
||||||
self,
|
|
||||||
sender: t.Any | None = None,
|
|
||||||
/,
|
|
||||||
*,
|
|
||||||
_sync_wrapper: c.Callable[
|
|
||||||
[c.Callable[..., t.Any]], c.Callable[..., c.Coroutine[t.Any, t.Any, t.Any]]
|
|
||||||
]
|
|
||||||
| None = None,
|
|
||||||
**kwargs: t.Any,
|
|
||||||
) -> list[tuple[c.Callable[..., t.Any], t.Any]]:
|
|
||||||
"""Await all receivers that are connected to the given ``sender``
|
|
||||||
or :data:`ANY`. Each receiver is called with ``sender`` as a positional
|
|
||||||
argument along with any extra keyword arguments. Return a list of
|
|
||||||
``(receiver, return value)`` tuples.
|
|
||||||
|
|
||||||
The order receivers are called is undefined, but can be influenced by
|
|
||||||
setting :attr:`set_class`.
|
|
||||||
|
|
||||||
If a receiver raises an exception, that exception will propagate up.
|
|
||||||
This makes debugging straightforward, with an assumption that correctly
|
|
||||||
implemented receivers will not raise.
|
|
||||||
|
|
||||||
:param sender: Call receivers connected to this sender, in addition to
|
|
||||||
those connected to :data:`ANY`.
|
|
||||||
:param _sync_wrapper: Will be called on any receivers that are sync
|
|
||||||
callables to turn them into async coroutines. For example,
|
|
||||||
could call the receiver in a thread.
|
|
||||||
:param kwargs: Extra keyword arguments to pass to each receiver.
|
|
||||||
|
|
||||||
.. versionadded:: 1.7
|
|
||||||
"""
|
|
||||||
if self.is_muted:
|
|
||||||
return []
|
|
||||||
|
|
||||||
results = []
|
|
||||||
|
|
||||||
for receiver in self.receivers_for(sender):
|
|
||||||
if not iscoroutinefunction(receiver):
|
|
||||||
if _sync_wrapper is None:
|
|
||||||
raise RuntimeError("Cannot send to a non-coroutine function.")
|
|
||||||
|
|
||||||
result = await _sync_wrapper(receiver)(sender, **kwargs)
|
|
||||||
else:
|
|
||||||
result = await receiver(sender, **kwargs)
|
|
||||||
|
|
||||||
results.append((receiver, result))
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
def has_receivers_for(self, sender: t.Any) -> bool:
|
|
||||||
"""Check if there is at least one receiver that will be called with the
|
|
||||||
given ``sender``. A receiver connected to :data:`ANY` will always be
|
|
||||||
called, regardless of sender. Does not check if weakly referenced
|
|
||||||
receivers are still live. See :meth:`receivers_for` for a stronger
|
|
||||||
search.
|
|
||||||
|
|
||||||
:param sender: Check for receivers connected to this sender, in addition
|
|
||||||
to those connected to :data:`ANY`.
|
|
||||||
"""
|
|
||||||
if not self.receivers:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if self._by_sender[ANY_ID]:
|
|
||||||
return True
|
|
||||||
|
|
||||||
if sender is ANY:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return make_id(sender) in self._by_sender
|
|
||||||
|
|
||||||
def receivers_for(
|
|
||||||
self, sender: t.Any
|
|
||||||
) -> c.Generator[c.Callable[..., t.Any], None, None]:
|
|
||||||
"""Yield each receiver to be called for ``sender``, in addition to those
|
|
||||||
to be called for :data:`ANY`. Weakly referenced receivers that are not
|
|
||||||
live will be disconnected and skipped.
|
|
||||||
|
|
||||||
:param sender: Yield receivers connected to this sender, in addition
|
|
||||||
to those connected to :data:`ANY`.
|
|
||||||
"""
|
|
||||||
# TODO: test receivers_for(ANY)
|
|
||||||
if not self.receivers:
|
|
||||||
return
|
|
||||||
|
|
||||||
sender_id = make_id(sender)
|
|
||||||
|
|
||||||
if sender_id in self._by_sender:
|
|
||||||
ids = self._by_sender[ANY_ID] | self._by_sender[sender_id]
|
|
||||||
else:
|
|
||||||
ids = self._by_sender[ANY_ID].copy()
|
|
||||||
|
|
||||||
for receiver_id in ids:
|
|
||||||
receiver = self.receivers.get(receiver_id)
|
|
||||||
|
|
||||||
if receiver is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if isinstance(receiver, weakref.ref):
|
|
||||||
strong = receiver()
|
|
||||||
|
|
||||||
if strong is None:
|
|
||||||
self._disconnect(receiver_id, ANY_ID)
|
|
||||||
continue
|
|
||||||
|
|
||||||
yield strong
|
|
||||||
else:
|
|
||||||
yield receiver
|
|
||||||
|
|
||||||
def disconnect(self, receiver: c.Callable[..., t.Any], sender: t.Any = ANY) -> None:
|
|
||||||
"""Disconnect ``receiver`` from being called when the signal is sent by
|
|
||||||
``sender``.
|
|
||||||
|
|
||||||
:param receiver: A connected receiver callable.
|
|
||||||
:param sender: Disconnect from only this sender. By default, disconnect
|
|
||||||
from all senders.
|
|
||||||
"""
|
|
||||||
sender_id: c.Hashable
|
|
||||||
|
|
||||||
if sender is ANY:
|
|
||||||
sender_id = ANY_ID
|
|
||||||
else:
|
|
||||||
sender_id = make_id(sender)
|
|
||||||
|
|
||||||
receiver_id = make_id(receiver)
|
|
||||||
self._disconnect(receiver_id, sender_id)
|
|
||||||
|
|
||||||
if (
|
|
||||||
"receiver_disconnected" in self.__dict__
|
|
||||||
and self.receiver_disconnected.receivers
|
|
||||||
):
|
|
||||||
self.receiver_disconnected.send(self, receiver=receiver, sender=sender)
|
|
||||||
|
|
||||||
def _disconnect(self, receiver_id: c.Hashable, sender_id: c.Hashable) -> None:
|
|
||||||
if sender_id == ANY_ID:
|
|
||||||
if self._by_receiver.pop(receiver_id, None) is not None:
|
|
||||||
for bucket in self._by_sender.values():
|
|
||||||
bucket.discard(receiver_id)
|
|
||||||
|
|
||||||
self.receivers.pop(receiver_id, None)
|
|
||||||
else:
|
|
||||||
self._by_sender[sender_id].discard(receiver_id)
|
|
||||||
self._by_receiver[receiver_id].discard(sender_id)
|
|
||||||
|
|
||||||
def _make_cleanup_receiver(
|
|
||||||
self, receiver_id: c.Hashable
|
|
||||||
) -> c.Callable[[weakref.ref[c.Callable[..., t.Any]]], None]:
|
|
||||||
"""Create a callback function to disconnect a weakly referenced
|
|
||||||
receiver when it is garbage collected.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def cleanup(ref: weakref.ref[c.Callable[..., t.Any]]) -> None:
|
|
||||||
# If the interpreter is shutting down, disconnecting can result in a
|
|
||||||
# weird ignored exception. Don't call it in that case.
|
|
||||||
if not sys.is_finalizing():
|
|
||||||
self._disconnect(receiver_id, ANY_ID)
|
|
||||||
|
|
||||||
return cleanup
|
|
||||||
|
|
||||||
def _make_cleanup_sender(
|
|
||||||
self, sender_id: c.Hashable
|
|
||||||
) -> c.Callable[[weakref.ref[t.Any]], None]:
|
|
||||||
"""Create a callback function to disconnect all receivers for a weakly
|
|
||||||
referenced sender when it is garbage collected.
|
|
||||||
"""
|
|
||||||
assert sender_id != ANY_ID
|
|
||||||
|
|
||||||
def cleanup(ref: weakref.ref[t.Any]) -> None:
|
|
||||||
self._weak_senders.pop(sender_id, None)
|
|
||||||
|
|
||||||
for receiver_id in self._by_sender.pop(sender_id, ()):
|
|
||||||
self._by_receiver[receiver_id].discard(sender_id)
|
|
||||||
|
|
||||||
return cleanup
|
|
||||||
|
|
||||||
def _cleanup_bookkeeping(self) -> None:
|
|
||||||
"""Prune unused sender/receiver bookkeeping. Not threadsafe.
|
|
||||||
|
|
||||||
Connecting & disconnecting leaves behind a small amount of bookkeeping
|
|
||||||
data. Typical workloads using Blinker, for example in most web apps,
|
|
||||||
Flask, CLI scripts, etc., are not adversely affected by this
|
|
||||||
bookkeeping.
|
|
||||||
|
|
||||||
With a long-running process performing dynamic signal routing with high
|
|
||||||
volume, e.g. connecting to function closures, senders are all unique
|
|
||||||
object instances. Doing all of this over and over may cause memory usage
|
|
||||||
to grow due to extraneous bookkeeping. (An empty ``set`` for each stale
|
|
||||||
sender/receiver pair.)
|
|
||||||
|
|
||||||
This method will prune that bookkeeping away, with the caveat that such
|
|
||||||
pruning is not threadsafe. The risk is that cleanup of a fully
|
|
||||||
disconnected receiver/sender pair occurs while another thread is
|
|
||||||
connecting that same pair. If you are in the highly dynamic, unique
|
|
||||||
receiver/sender situation that has lead you to this method, that failure
|
|
||||||
mode is perhaps not a big deal for you.
|
|
||||||
"""
|
|
||||||
for mapping in (self._by_sender, self._by_receiver):
|
|
||||||
for ident, bucket in list(mapping.items()):
|
|
||||||
if not bucket:
|
|
||||||
mapping.pop(ident, None)
|
|
||||||
|
|
||||||
def _clear_state(self) -> None:
|
|
||||||
"""Disconnect all receivers and senders. Useful for tests."""
|
|
||||||
self._weak_senders.clear()
|
|
||||||
self.receivers.clear()
|
|
||||||
self._by_sender.clear()
|
|
||||||
self._by_receiver.clear()
|
|
||||||
|
|
||||||
|
|
||||||
class NamedSignal(Signal):
|
|
||||||
"""A named generic notification emitter. The name is not used by the signal
|
|
||||||
itself, but matches the key in the :class:`Namespace` that it belongs to.
|
|
||||||
|
|
||||||
:param name: The name of the signal within the namespace.
|
|
||||||
:param doc: The docstring for the signal.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, name: str, doc: str | None = None) -> None:
|
|
||||||
super().__init__(doc)
|
|
||||||
|
|
||||||
#: The name of this signal.
|
|
||||||
self.name: str = name
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
base = super().__repr__()
|
|
||||||
return f"{base[:-1]}; {self.name!r}>" # noqa: E702
|
|
||||||
|
|
||||||
|
|
||||||
class Namespace(dict[str, NamedSignal]):
|
|
||||||
"""A dict mapping names to signals."""
|
|
||||||
|
|
||||||
def signal(self, name: str, doc: str | None = None) -> NamedSignal:
|
|
||||||
"""Return the :class:`NamedSignal` for the given ``name``, creating it
|
|
||||||
if required. Repeated calls with the same name return the same signal.
|
|
||||||
|
|
||||||
:param name: The name of the signal.
|
|
||||||
:param doc: The docstring of the signal.
|
|
||||||
"""
|
|
||||||
if name not in self:
|
|
||||||
self[name] = NamedSignal(name, doc)
|
|
||||||
|
|
||||||
return self[name]
|
|
||||||
|
|
||||||
|
|
||||||
class _PNamespaceSignal(t.Protocol):
|
|
||||||
def __call__(self, name: str, doc: str | None = None) -> NamedSignal: ...
|
|
||||||
|
|
||||||
|
|
||||||
default_namespace: Namespace = Namespace()
|
|
||||||
"""A default :class:`Namespace` for creating named signals. :func:`signal`
|
|
||||||
creates a :class:`NamedSignal` in this namespace.
|
|
||||||
"""
|
|
||||||
|
|
||||||
signal: _PNamespaceSignal = default_namespace.signal
|
|
||||||
"""Return a :class:`NamedSignal` in :data:`default_namespace` with the given
|
|
||||||
``name``, creating it if required. Repeated calls with the same name return the
|
|
||||||
same signal.
|
|
||||||
"""
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
pip
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
Metadata-Version: 2.4
|
|
||||||
Name: certifi
|
|
||||||
Version: 2026.6.17
|
|
||||||
Summary: Python package for providing Mozilla's CA Bundle.
|
|
||||||
Home-page: https://github.com/certifi/python-certifi
|
|
||||||
Author: Kenneth Reitz
|
|
||||||
Author-email: me@kennethreitz.com
|
|
||||||
License: MPL-2.0
|
|
||||||
Project-URL: Source, https://github.com/certifi/python-certifi
|
|
||||||
Classifier: Development Status :: 5 - Production/Stable
|
|
||||||
Classifier: Intended Audience :: Developers
|
|
||||||
Classifier: License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)
|
|
||||||
Classifier: Natural Language :: English
|
|
||||||
Classifier: Programming Language :: Python
|
|
||||||
Classifier: Programming Language :: Python :: 3
|
|
||||||
Classifier: Programming Language :: Python :: 3 :: Only
|
|
||||||
Classifier: Programming Language :: Python :: 3.7
|
|
||||||
Classifier: Programming Language :: Python :: 3.8
|
|
||||||
Classifier: Programming Language :: Python :: 3.9
|
|
||||||
Classifier: Programming Language :: Python :: 3.10
|
|
||||||
Classifier: Programming Language :: Python :: 3.11
|
|
||||||
Classifier: Programming Language :: Python :: 3.12
|
|
||||||
Classifier: Programming Language :: Python :: 3.13
|
|
||||||
Classifier: Programming Language :: Python :: 3.14
|
|
||||||
Requires-Python: >=3.7
|
|
||||||
License-File: LICENSE
|
|
||||||
Dynamic: author
|
|
||||||
Dynamic: author-email
|
|
||||||
Dynamic: classifier
|
|
||||||
Dynamic: description
|
|
||||||
Dynamic: home-page
|
|
||||||
Dynamic: license
|
|
||||||
Dynamic: license-file
|
|
||||||
Dynamic: project-url
|
|
||||||
Dynamic: requires-python
|
|
||||||
Dynamic: summary
|
|
||||||
|
|
||||||
Certifi: Python SSL Certificates
|
|
||||||
================================
|
|
||||||
|
|
||||||
Certifi provides Mozilla's carefully curated collection of Root Certificates for
|
|
||||||
validating the trustworthiness of SSL certificates while verifying the identity
|
|
||||||
of TLS hosts. It has been extracted from the `Requests`_ project.
|
|
||||||
|
|
||||||
Installation
|
|
||||||
------------
|
|
||||||
|
|
||||||
``certifi`` is available on PyPI. Simply install it with ``pip``::
|
|
||||||
|
|
||||||
$ pip install certifi
|
|
||||||
|
|
||||||
Usage
|
|
||||||
-----
|
|
||||||
|
|
||||||
To reference the installed certificate authority (CA) bundle, you can use the
|
|
||||||
built-in function::
|
|
||||||
|
|
||||||
>>> import certifi
|
|
||||||
|
|
||||||
>>> certifi.where()
|
|
||||||
'/usr/local/lib/python3.7/site-packages/certifi/cacert.pem'
|
|
||||||
|
|
||||||
Or from the command line::
|
|
||||||
|
|
||||||
$ python -m certifi
|
|
||||||
/usr/local/lib/python3.7/site-packages/certifi/cacert.pem
|
|
||||||
|
|
||||||
Enjoy!
|
|
||||||
|
|
||||||
.. _`Requests`: https://requests.readthedocs.io/en/latest/
|
|
||||||
|
|
||||||
Addition/Removal of Certificates
|
|
||||||
--------------------------------
|
|
||||||
|
|
||||||
Certifi does not support any addition/removal or other modification of the
|
|
||||||
CA trust store content. This project is intended to provide a reliable and
|
|
||||||
highly portable root of trust to python deployments. Look to upstream projects
|
|
||||||
for methods to use alternate trust.
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
certifi-2026.6.17.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
|
||||||
certifi-2026.6.17.dist-info/METADATA,sha256=6hXAnt0a2el7xm2e9xvPuRCntZLjdKCkN81e47E0wN8,2474
|
|
||||||
certifi-2026.6.17.dist-info/RECORD,,
|
|
||||||
certifi-2026.6.17.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
||||||
certifi-2026.6.17.dist-info/licenses/LICENSE,sha256=6TcW2mucDVpKHfYP5pWzcPBpVgPSH2-D8FPkLPwQyvc,989
|
|
||||||
certifi-2026.6.17.dist-info/top_level.txt,sha256=KMu4vUCfsjLrkPbSNdgdekS-pVJzBAJFO__nI8NF6-U,8
|
|
||||||
certifi/__init__.py,sha256=-W1R_y8WCaSkT1tdjuxH_zTBZY1YH6xQgdN1nbBajOE,94
|
|
||||||
certifi/__main__.py,sha256=xBBoj905TUWBLRGANOcf7oi6e-3dMP4cEoG9OyMs11g,243
|
|
||||||
certifi/__pycache__/__init__.cpython-312.pyc,,
|
|
||||||
certifi/__pycache__/__main__.cpython-312.pyc,,
|
|
||||||
certifi/__pycache__/core.cpython-312.pyc,,
|
|
||||||
certifi/cacert.pem,sha256=u8fpwB11UbuKFZtd7dmJuO484QWv9SK2jrGwG_hUyrA,234354
|
|
||||||
certifi/core.py,sha256=XFXycndG5pf37ayeF8N32HUuDafsyhkVMbO4BAPWHa0,3394
|
|
||||||
certifi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
Wheel-Version: 1.0
|
|
||||||
Generator: setuptools (82.0.1)
|
|
||||||
Root-Is-Purelib: true
|
|
||||||
Tag: py3-none-any
|
|
||||||
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
This package contains a modified version of ca-bundle.crt:
|
|
||||||
|
|
||||||
ca-bundle.crt -- Bundle of CA Root Certificates
|
|
||||||
|
|
||||||
This is a bundle of X.509 certificates of public Certificate Authorities
|
|
||||||
(CA). These were automatically extracted from Mozilla's root certificates
|
|
||||||
file (certdata.txt). This file can be found in the mozilla source tree:
|
|
||||||
https://hg.mozilla.org/mozilla-central/file/tip/security/nss/lib/ckfw/builtins/certdata.txt
|
|
||||||
It contains the certificates in PEM format and therefore
|
|
||||||
can be directly used with curl / libcurl / php_curl, or with
|
|
||||||
an Apache+mod_ssl webserver for SSL client authentication.
|
|
||||||
Just configure this file as the SSLCACertificateFile.#
|
|
||||||
|
|
||||||
***** BEGIN LICENSE BLOCK *****
|
|
||||||
This Source Code Form is subject to the terms of the Mozilla Public License,
|
|
||||||
v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain
|
|
||||||
one at http://mozilla.org/MPL/2.0/.
|
|
||||||
|
|
||||||
***** END LICENSE BLOCK *****
|
|
||||||
@(#) $RCSfile: certdata.txt,v $ $Revision: 1.80 $ $Date: 2011/11/03 15:11:58 $
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
certifi
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
from .core import contents, where
|
|
||||||
|
|
||||||
__all__ = ["contents", "where"]
|
|
||||||
__version__ = "2026.06.17"
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import argparse
|
|
||||||
|
|
||||||
from certifi import contents, where
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("-c", "--contents", action="store_true")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.contents:
|
|
||||||
print(contents())
|
|
||||||
else:
|
|
||||||
print(where())
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,83 +0,0 @@
|
|||||||
"""
|
|
||||||
certifi.py
|
|
||||||
~~~~~~~~~~
|
|
||||||
|
|
||||||
This module returns the installation location of cacert.pem or its contents.
|
|
||||||
"""
|
|
||||||
import sys
|
|
||||||
import atexit
|
|
||||||
|
|
||||||
def exit_cacert_ctx() -> None:
|
|
||||||
_CACERT_CTX.__exit__(None, None, None) # type: ignore[union-attr]
|
|
||||||
|
|
||||||
|
|
||||||
if sys.version_info >= (3, 11):
|
|
||||||
|
|
||||||
from importlib.resources import as_file, files
|
|
||||||
|
|
||||||
_CACERT_CTX = None
|
|
||||||
_CACERT_PATH = None
|
|
||||||
|
|
||||||
def where() -> str:
|
|
||||||
# This is slightly terrible, but we want to delay extracting the file
|
|
||||||
# in cases where we're inside of a zipimport situation until someone
|
|
||||||
# actually calls where(), but we don't want to re-extract the file
|
|
||||||
# on every call of where(), so we'll do it once then store it in a
|
|
||||||
# global variable.
|
|
||||||
global _CACERT_CTX
|
|
||||||
global _CACERT_PATH
|
|
||||||
if _CACERT_PATH is None:
|
|
||||||
# This is slightly janky, the importlib.resources API wants you to
|
|
||||||
# manage the cleanup of this file, so it doesn't actually return a
|
|
||||||
# path, it returns a context manager that will give you the path
|
|
||||||
# when you enter it and will do any cleanup when you leave it. In
|
|
||||||
# the common case of not needing a temporary file, it will just
|
|
||||||
# return the file system location and the __exit__() is a no-op.
|
|
||||||
#
|
|
||||||
# We also have to hold onto the actual context manager, because
|
|
||||||
# it will do the cleanup whenever it gets garbage collected, so
|
|
||||||
# we will also store that at the global level as well.
|
|
||||||
_CACERT_CTX = as_file(files("certifi").joinpath("cacert.pem"))
|
|
||||||
_CACERT_PATH = str(_CACERT_CTX.__enter__())
|
|
||||||
atexit.register(exit_cacert_ctx)
|
|
||||||
|
|
||||||
return _CACERT_PATH
|
|
||||||
|
|
||||||
def contents() -> str:
|
|
||||||
return files("certifi").joinpath("cacert.pem").read_text(encoding="ascii")
|
|
||||||
|
|
||||||
else:
|
|
||||||
|
|
||||||
from importlib.resources import path as get_path, read_text
|
|
||||||
|
|
||||||
_CACERT_CTX = None
|
|
||||||
_CACERT_PATH = None
|
|
||||||
|
|
||||||
def where() -> str:
|
|
||||||
# This is slightly terrible, but we want to delay extracting the
|
|
||||||
# file in cases where we're inside of a zipimport situation until
|
|
||||||
# someone actually calls where(), but we don't want to re-extract
|
|
||||||
# the file on every call of where(), so we'll do it once then store
|
|
||||||
# it in a global variable.
|
|
||||||
global _CACERT_CTX
|
|
||||||
global _CACERT_PATH
|
|
||||||
if _CACERT_PATH is None:
|
|
||||||
# This is slightly janky, the importlib.resources API wants you
|
|
||||||
# to manage the cleanup of this file, so it doesn't actually
|
|
||||||
# return a path, it returns a context manager that will give
|
|
||||||
# you the path when you enter it and will do any cleanup when
|
|
||||||
# you leave it. In the common case of not needing a temporary
|
|
||||||
# file, it will just return the file system location and the
|
|
||||||
# __exit__() is a no-op.
|
|
||||||
#
|
|
||||||
# We also have to hold onto the actual context manager, because
|
|
||||||
# it will do the cleanup whenever it gets garbage collected, so
|
|
||||||
# we will also store that at the global level as well.
|
|
||||||
_CACERT_CTX = get_path("certifi", "cacert.pem")
|
|
||||||
_CACERT_PATH = str(_CACERT_CTX.__enter__())
|
|
||||||
atexit.register(exit_cacert_ctx)
|
|
||||||
|
|
||||||
return _CACERT_PATH
|
|
||||||
|
|
||||||
def contents() -> str:
|
|
||||||
return read_text("certifi", "cacert.pem", encoding="ascii")
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
pip
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user