Compare commits
30 Commits
4366abba69
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -1,2 +1,4 @@
|
||||
__pycache__/
|
||||
orchestrateur/db.sqlite-shm
|
||||
venv/
|
||||
.env
|
||||
Vendored
+1
@@ -9,6 +9,7 @@
|
||||
"${workspaceFolder}/shared",
|
||||
"${workspaceFolder}/micro_ondes/esp_lora/lib"
|
||||
],
|
||||
"python.terminal.useEnvFile": true,
|
||||
"python.defaultInterpreterPath": "${workspaceFolder}/venv/bin/python",
|
||||
"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
|
||||
|
||||
# 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
|
||||
|
||||
# Copy the requirements file and install dependencies
|
||||
# Copy requirements from build context root or relative path
|
||||
COPY cloud/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy the rest of the application code
|
||||
# Copy application source code
|
||||
COPY cloud/ .
|
||||
COPY shared/ ./shared/
|
||||
|
||||
# Ensure the photo storage directory exists so the app doesn't crash on startup
|
||||
# Create photo storage directory
|
||||
RUN mkdir -p storage/dishPhotos
|
||||
|
||||
# Expose the port the app will run on
|
||||
EXPOSE 5000
|
||||
|
||||
# Use Gunicorn to run the application in production
|
||||
CMD ["python", "-m", "gunicorn", "--bind", "0.0.0.0:5000", "app:app"]
|
||||
# Call gunicorn directly
|
||||
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`
|
||||
+63
-27
@@ -3,9 +3,14 @@ import base64
|
||||
import uuid
|
||||
from flask import Flask, request, jsonify
|
||||
from pymongo import MongoClient
|
||||
|
||||
# Import your shared device types
|
||||
from shared import deviceTypes
|
||||
from APIs import generate, EdamamAPI
|
||||
import sys
|
||||
from microwaveCookPlanner import MicrowaveCookPlanner
|
||||
sys.path.insert(0, '..')
|
||||
try:
|
||||
from shared import config
|
||||
except ImportError:
|
||||
from ..shared import config
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
@@ -20,9 +25,14 @@ db = client["microwave_network_db"]
|
||||
cooking_collection = db["cooking_parameters"]
|
||||
device_network_collection = db["device_network"]
|
||||
|
||||
# Ensure the photo storage directory exists when the app starts
|
||||
PHOTO_DIR = "storage/dishPhotos"
|
||||
os.makedirs(PHOTO_DIR, exist_ok=True)
|
||||
# Ensure the camera image storage directory exists when the app starts
|
||||
CAMERA_IMAGE_DIR = "storage/dishCameraImages"
|
||||
os.makedirs(CAMERA_IMAGE_DIR, exist_ok=True)
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Classes
|
||||
# ---------------------------------------------------------
|
||||
microwave_cook_planner = MicrowaveCookPlanner()
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Routes
|
||||
@@ -30,7 +40,9 @@ os.makedirs(PHOTO_DIR, exist_ok=True)
|
||||
|
||||
@app.route("/")
|
||||
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"])
|
||||
@@ -40,37 +52,56 @@ def cooking_params():
|
||||
if not data:
|
||||
return jsonify({"error": "Invalid or missing JSON payload"}), 400
|
||||
|
||||
# 1. Handle the Photo
|
||||
photo_b64 = data.get("photo")
|
||||
if photo_b64:
|
||||
# Generate a unique filename using UUID to avoid overwriting
|
||||
# Extract user or device parameters (with fallback defaults)
|
||||
height_cm = float(data.get("dish_height", 4.0))
|
||||
initial_temp_c = float(data.get("ir_initial_temp", 20.0)) # e.g., 4.0 for fridge, -18.0 for freezer
|
||||
microwave_wattage = int(data.get("microwave_wattage", 900)) # e.g., 900W
|
||||
defrost_mode = bool(data.get("defrost_mode", False)) # True for defrost, False for cook/reheat
|
||||
print("Parsed parameters - Height (cm):", height_cm, "Initial Temp (C):", initial_temp_c, "Microwave Wattage:", microwave_wattage, "Defrost Mode:", defrost_mode)
|
||||
|
||||
# 1. Handle the Camera Image
|
||||
camera_image_b64 = data.get("camera_image")
|
||||
filepath = None
|
||||
|
||||
if camera_image_b64:
|
||||
filename = f"dish_{uuid.uuid4().hex}.jpg"
|
||||
filepath = os.path.join(PHOTO_DIR, filename)
|
||||
filepath = os.path.join(CAMERA_IMAGE_DIR, filename)
|
||||
|
||||
try:
|
||||
# Decode the base64 string and save it as a binary file
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(base64.b64decode(photo_b64))
|
||||
f.write(base64.b64decode(camera_image_b64))
|
||||
|
||||
# Replace the giant base64 string in the dictionary with the local file path
|
||||
# so we don't bloat the MongoDB document
|
||||
data["photo"] = filepath
|
||||
data["camera_image"] = filepath
|
||||
|
||||
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:
|
||||
# Insert the dictionary directly into Mongo (it will retain your exact JSON keys)
|
||||
cooking_collection.insert_one(data)
|
||||
|
||||
# Remove the Mongo-injected '_id' object before returning the response
|
||||
data.pop("_id", None)
|
||||
return jsonify({"message": "Cooking parameters saved successfully", "data": data}), 201
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({"error": f"Database error: {str(e)}"}), 500
|
||||
|
||||
|
||||
# 5. Return complete output
|
||||
return jsonify(cook_plan), 201
|
||||
|
||||
@app.route("/device-network", methods=["POST"])
|
||||
def device_network():
|
||||
@@ -89,7 +120,12 @@ def device_network():
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({"error": f"Database error: {str(e)}"}), 500
|
||||
|
||||
|
||||
@app.route("/debug", methods=["GET"])
|
||||
def debug():
|
||||
image_path = "microwaveDish.jpg"
|
||||
edamam = EdamamAPI()
|
||||
return edamam.analyze_dish_image(image_path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=True)
|
||||
app.run(debug=config.DEBUG)
|
||||
@@ -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
|
||||
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
|
||||
if [ -z "$1" ]; then
|
||||
echo "Usage: ./deploy.sh [wifi|lora|rpi|all]"
|
||||
echo "Usage: ./deploy.sh [wifi|mqtt|lora|rpi|all]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -79,6 +79,9 @@ case $CIBLE in
|
||||
"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")
|
||||
deploy_to_esp "micro_ondes/esp_lora" "$PORT_ESP_LORA" "ESP-LORA"
|
||||
;;
|
||||
@@ -92,6 +95,6 @@ case $CIBLE in
|
||||
# Ajoute les autres ici
|
||||
;;
|
||||
*)
|
||||
echo "Cible inconnue. Utilise 'wifi', 'lora' ou 'all'."
|
||||
echo "Cible inconnue. Utilise 'wifi', 'lora', 'mqtt' ou 'all'."
|
||||
;;
|
||||
esac
|
||||
+12
-1
@@ -1,5 +1,16 @@
|
||||
|
||||
# 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 |
|
||||
|
||||
+139
-29
@@ -1,6 +1,13 @@
|
||||
import _thread
|
||||
from machine import Pin
|
||||
from shared import get_lora, get_uart, deviceTypes, config
|
||||
from machine import Pin, SoftI2C
|
||||
from shared.safeQueue import SafeQueue
|
||||
from shared import get_lora, get_uart, deviceTypes, config, cookingState
|
||||
from shared.uart_comm import UARTCommand, UARTCommandType
|
||||
from shared.sensors import RGBLED
|
||||
from shared.logging import log
|
||||
from shared.lora_device import LoraCommands
|
||||
import framebuf
|
||||
import ssd1306
|
||||
import time
|
||||
|
||||
# --- Configuration Matérielle ---
|
||||
@@ -18,52 +25,155 @@ except Exception:
|
||||
# --- Initialisation LoRa ---
|
||||
lora = get_lora()
|
||||
lora.configure(freq=868.1, sf=7)
|
||||
data_queue = SafeQueue()
|
||||
|
||||
# --- Création des lEDs RGB ---
|
||||
magnetron_led = RGBLED(red_pin=48, green_pin=47, blue_pin=33)
|
||||
magnetron_led.color = RGBLED.WHITE_YELLOW
|
||||
magnetron_led.off()
|
||||
|
||||
# --- Création de l'écran OLED ---
|
||||
scl_pin = Pin(18, Pin.OUT, pull=Pin.PULL_UP)
|
||||
sda_pin = Pin(17, Pin.OUT, pull=Pin.PULL_UP)
|
||||
display_i2c = SoftI2C(scl=scl_pin, sda=sda_pin, freq=100000)
|
||||
display = ssd1306.SSD1306_I2C(128, 64, display_i2c, addr=0x3C)
|
||||
display.text("Booting...", 1, 2, 1)
|
||||
display.show()
|
||||
|
||||
print(f"ESP32 initialisé avec l'ID : '{DEVICE_ID}' (Type : {deviceTypes.DEVICE_TYPES['MICROWAVE']})")
|
||||
|
||||
PING_PAYLOAD = {
|
||||
"id": DEVICE_ID,
|
||||
"type": deviceTypes.DEVICE_TYPES["MICROWAVE"]
|
||||
}
|
||||
|
||||
def heartbeat_loop():
|
||||
last_heartbeat_time = 0
|
||||
while True:
|
||||
print(f"\nESP32 : Envoi du Heartbeat...")
|
||||
# Envoi périodique
|
||||
ping_payload = {
|
||||
"id": DEVICE_ID,
|
||||
"type": deviceTypes.DEVICE_TYPES["MICROWAVE"]
|
||||
}
|
||||
lora.send(ping_payload)
|
||||
now = time.time()
|
||||
|
||||
# Le receive_packet est maintenant protégé par le lock dans lora_device
|
||||
# Si le main thread utilise la radio, ce thread attendra son tour
|
||||
paquet = lora.receive_packet(timeout_ms=2000)
|
||||
# 1. Send periodic heartbeat
|
||||
if now - last_heartbeat_time >= config.LORA_HEARTBEAT_INTERVAL:
|
||||
last_heartbeat_time = now
|
||||
print("\nESP32 : Envoi du Heartbeat...")
|
||||
lora.send(PING_PAYLOAD)
|
||||
|
||||
if paquet and not paquet["raw"]:
|
||||
donnees = paquet["data"]
|
||||
# Vérification si le paquet reçu est bien la réponse attendue de l'orchestrateur
|
||||
if donnees.get("type") == deviceTypes.DEVICE_TYPES["ORCHESTRATOR"]:
|
||||
print(f"ESP32 : Réponse reçue de l'orchestrateur '{donnees.get('id')}' ! [Statut: ALIVE]")
|
||||
else:
|
||||
print(f"ESP32 : Paquet reçu d'un type inattendu : {donnees.get('type')}")
|
||||
else:
|
||||
print("ESP32 : Pas de réponse de l'orchestrateur (Le RPI est-il éteint ?)")
|
||||
# 2. Increase listen window to 300ms so radio stays active in RX mode
|
||||
paquet = lora.receive_reliable(timeout_ms=300)
|
||||
|
||||
time.sleep(config.HEARTBEAT_INTERVAL)
|
||||
if paquet is not None:
|
||||
log(f"[LoRa Thread] New Packet Received: {paquet}")
|
||||
data_queue.put(paquet)
|
||||
|
||||
time.sleep_ms(10)
|
||||
|
||||
# UART
|
||||
uart_device = get_uart(uart_id=1, tx_pin=46, rx_pin=45)
|
||||
|
||||
# Lancer la boucle de heartbeat dans un thread séparé
|
||||
try:
|
||||
_thread.stack_size(16 * 1024)
|
||||
except Exception:
|
||||
pass
|
||||
_thread.start_new_thread(heartbeat_loop, ())
|
||||
|
||||
# Cooking parameters
|
||||
cooking_state = None
|
||||
def cooking_state_temperature_provider():
|
||||
return 22.0, 29.0 # TODO Remplacer par la lecture réelle de la température du plat et de l'air ambiant
|
||||
|
||||
def cooking_state_on_state_change(state):
|
||||
print(f"[Main] Cooking state changed to: {state.state}")
|
||||
|
||||
# Send to the Wifi board the current state
|
||||
uart_device.send_as_command(UARTCommand(UARTCommandType.COOKING_STATE_UPDATE, {"state": state.state}))
|
||||
# Send to the orchestrator the current state
|
||||
lora.send_reliable({"id": DEVICE_ID, "new_cooking_state": state.state})
|
||||
|
||||
display.text(cookingState.CookingStates.get_state_name(state.state), 1, 2, 1)
|
||||
display.show()
|
||||
|
||||
if state.paused or state.state == cookingState.CookingStates.DONE or state.state == cookingState.CookingStates.IDLE:
|
||||
magnetron_led.off()
|
||||
else:
|
||||
magnetron_led.on()
|
||||
|
||||
|
||||
if state.state == cookingState.CookingStates.COOKING:
|
||||
pass
|
||||
if state.state == cookingState.CookingStates.STIRRING_REQUIRED:
|
||||
pass
|
||||
if state.state == cookingState.CookingStates.DONE:
|
||||
pass
|
||||
if state.state == cookingState.CookingStates.ALERT:
|
||||
pass
|
||||
|
||||
def cooking_state_on_refresh(state):
|
||||
# TODO Show screen information
|
||||
pass
|
||||
|
||||
def cooking_state_on_pause(state):
|
||||
# If the cooking is unpaused and was in STIRRING_REQUIRED or ALERT state, we set the state back to COOKING.
|
||||
if not state.paused and (state.state == cookingState.CookingStates.STIRRING_REQUIRED or state.state == cookingState.CookingStates.ALERT):
|
||||
state.set_state(cookingState.CookingStates.COOKING)
|
||||
|
||||
# TODO send_reliable lora message to orchestrator about pause/resume state
|
||||
|
||||
|
||||
# --- MAIN APPLICATION THREAD ---
|
||||
print("[Main] Main execution path active.")
|
||||
while True:
|
||||
# 1. Listen for incoming UART serial packets from the WROOM board
|
||||
while uart_device.any():
|
||||
command = uart_device.read()
|
||||
print(f"[Main] Received command from WiFi Board: {command}")
|
||||
|
||||
uart_device.send(f"Hello from esp-32 lora ID {DEVICE_ID}")
|
||||
command = uart_device.read_as_command()
|
||||
if command:
|
||||
print(f"[Main] Received command from WiFi Board: {command.command_type}")
|
||||
if command.command_type == UARTCommandType.COOKING_PARAMS:
|
||||
# Handle cooking parameters command
|
||||
params = command.payload
|
||||
print(f"[Main] Cooking parameters received: {params}")
|
||||
cooking_state = cookingState.CookingState(
|
||||
cook_time=params["cook_time"],
|
||||
power_level=params["power_level"],
|
||||
target_temp=params["target_temp"]
|
||||
)
|
||||
cooking_state.set_temperature_provider(cooking_state_temperature_provider)
|
||||
cooking_state.set_state_change_callback(cooking_state_on_state_change)
|
||||
cooking_state.set_refresh_callback(cooking_state_on_refresh)
|
||||
cooking_state.set_pause_callback(cooking_state_on_pause)
|
||||
time.sleep_ms(20) # Before sending back right away
|
||||
cooking_state_on_state_change(cooking_state)
|
||||
|
||||
# 2. Send local metrics over the wire to the WiFi board every few seconds
|
||||
# uart_device.send("Data Pack: LoRa Link RSSI -72dBm")
|
||||
else:
|
||||
print(f"[Main] Unknown command type received: {command.command_type}")
|
||||
# 2. Listen for incoming LoRa packets from the orchestrator
|
||||
while not data_queue.empty():
|
||||
paquet = data_queue.get()
|
||||
if paquet and not paquet["raw"]:
|
||||
data = paquet["data"]
|
||||
# Commands
|
||||
if "action" in data:
|
||||
if data["action"] == LoraCommands.TOGGLE_PAUSE:
|
||||
if cooking_state != None:
|
||||
if (cooking_state.state == cookingState.CookingStates.DONE):
|
||||
print("[Main] Cooking is done. We reset the microwave for the next cooking session.")
|
||||
cooking_state.set_state(cookingState.CookingStates.IDLE)
|
||||
time.sleep_ms(20) # Before sending back right away
|
||||
cooking_state = None
|
||||
else:
|
||||
cooking_state.toggle_pause()
|
||||
if cooking_state.paused:
|
||||
print("[Main] Cooking paused via orchestrator command.")
|
||||
else:
|
||||
print("[Main] Cooking resumed via orchestrator command.")
|
||||
else:
|
||||
log("[Main] No active cooking state to toggle pause/resume.")
|
||||
|
||||
time.sleep_ms(200)
|
||||
# uart_device.send(f"Hello from esp-32 lora ID {DEVICE_ID}")
|
||||
|
||||
# Cooking State Update
|
||||
if cooking_state != None:
|
||||
cooking_state.update_tick()
|
||||
print(f"[Main] Cooking state : State : {cooking_state.state}, Temperature: {cooking_state.current_dish_temp}, Paused: {cooking_state.paused}, Remaining Time: {cooking_state.get_remaining_time():.2f}s, Estimated Remaining Time: {cooking_state.get_remaining_time_estimation():.2f}s")
|
||||
|
||||
time.sleep_ms(500)
|
||||
@@ -14,10 +14,10 @@ while True:
|
||||
mesures = {"id": "ESP32_Salon", "temp": 22.4, "hum": 55.2}
|
||||
|
||||
# Envoi direct (le pilote s'occupe de mettre le groupe \x02)
|
||||
lora.send(b'\x02' + lora.send_json_bytes_helper if False else bytes([2]) + lora.send_helper if False else b'\x02' + __import__('ujson').dumps(mesures).encode('utf-8'))
|
||||
lora.send_reliable(b'\x02' + lora.send_json_bytes_helper if False else bytes([2]) + lora.send_helper if False else b'\x02' + __import__('ujson').dumps(mesures).encode('utf-8'))
|
||||
|
||||
# Réception propre
|
||||
paquet = lora.receive_packet(3000)
|
||||
paquet = lora.receive_reliable(3000)
|
||||
if paquet:
|
||||
# paquet est un dict : {"group": 2, "data": {...}, "raw": False}
|
||||
print(f"ESP32 : Message reçu du groupe {paquet['group']}")
|
||||
|
||||
@@ -1,19 +1,58 @@
|
||||
# This file is executed on every boot (including wake-boot from deepsleep)
|
||||
#import esp
|
||||
#esp.osdebug(None)
|
||||
import esp
|
||||
from machine import Pin
|
||||
esp.osdebug(True)
|
||||
#import webrepl
|
||||
#webrepl.start()
|
||||
|
||||
def do_connect(ssid, pwd):
|
||||
import network
|
||||
sta_if = network.WLAN(network.STA_IF)
|
||||
if not sta_if.isconnected():
|
||||
print('connecting to network...')
|
||||
sta_if.active(True)
|
||||
sta_if.connect(ssid, pwd)
|
||||
while not sta_if.isconnected():
|
||||
pass
|
||||
print('network config:', sta_if.ifconfig())
|
||||
# def do_connect(ssid, pwd):
|
||||
# import network
|
||||
# sta_if = network.WLAN(network.STA_IF)
|
||||
# sta_if.config(pm=sta_if.PM_NONE)
|
||||
# if not sta_if.isconnected():
|
||||
# print('connecting to network...')
|
||||
# sta_if.active(True)
|
||||
# sta_if.connect(ssid, pwd)
|
||||
# while not sta_if.isconnected():
|
||||
# pass
|
||||
# print('network config:', sta_if.ifconfig())
|
||||
|
||||
import network
|
||||
import time
|
||||
|
||||
def do_connect(ssid, password):
|
||||
wlan = network.WLAN(network.STA_IF)
|
||||
|
||||
# 1. ALWAYS activate the interface FIRST
|
||||
if not wlan.active():
|
||||
wlan.active(True)
|
||||
|
||||
# 2. Configure Wi-Fi options AFTER activation
|
||||
try:
|
||||
# Disable Wi-Fi modem sleep (0 = PM_NONE)
|
||||
wlan.config(pm=0)
|
||||
except Exception as e:
|
||||
print("[Wi-Fi] Warning: Failed to set power management:", e)
|
||||
|
||||
# 3. Connect to the access point
|
||||
if not wlan.isconnected():
|
||||
print(f"[Wi-Fi] Connecting to {ssid}...")
|
||||
wlan.connect(ssid, password)
|
||||
|
||||
timeout = 15
|
||||
start_time = time.time()
|
||||
while not wlan.isconnected():
|
||||
if time.time() - start_time > timeout:
|
||||
print("[Wi-Fi] Connection timed out!")
|
||||
return False
|
||||
time.sleep(0.5)
|
||||
|
||||
print("[Wi-Fi] Connected! Network config:", wlan.ifconfig())
|
||||
return True
|
||||
|
||||
# Attempt to connect to WiFi network
|
||||
do_connect("Smartwave-1", 'Smartwave-prot-1')
|
||||
|
||||
# Set PIN 27 as GND for the temperature sensor (MLX90614)
|
||||
sensor_gnd = Pin(27, Pin.OUT)
|
||||
sensor_gnd.value(0)
|
||||
+322
-95
@@ -1,127 +1,354 @@
|
||||
import _thread
|
||||
import select
|
||||
from machine import Pin
|
||||
from shared import get_mqtt_client, get_uart, config
|
||||
import gc
|
||||
import sys
|
||||
import time
|
||||
import ujson as json
|
||||
import uasyncio as asyncio
|
||||
from machine import Pin, I2C
|
||||
|
||||
# Simple thread-safe queue list
|
||||
msg_queue = []
|
||||
queue_lock = _thread.allocate_lock()
|
||||
|
||||
def queue_publish(topic, payload):
|
||||
"""Safely queues a message from the main thread."""
|
||||
with queue_lock:
|
||||
msg_queue.append((topic, payload))
|
||||
|
||||
# --- Hardware & Client Setup ---
|
||||
vext = Pin(19, Pin.OUT)
|
||||
vext.value(0)
|
||||
time.sleep_ms(100)
|
||||
# 1. Clean memory immediately before performing any operations
|
||||
gc.collect()
|
||||
|
||||
# --- READ DEVICE ID ---
|
||||
try:
|
||||
with open("device_id.txt", "r") as f:
|
||||
DEVICE_ID = f.read().strip()
|
||||
except Exception:
|
||||
DEVICE_ID = "ESP32_Inconnu"
|
||||
|
||||
|
||||
# --- GLOBAL APP STATE ---
|
||||
orchestrator_id = None
|
||||
cooking_state = None
|
||||
mqtt_connected = False
|
||||
should_unsubscribe_hello = False
|
||||
|
||||
# --- ASYNC SIGNALS & QUEUES ---
|
||||
# Event to signal when orchestrator requests sensor data (prevents MQTT lock deadlock)
|
||||
sensor_request_event = None
|
||||
|
||||
# --- MQTT SETUP ---
|
||||
from shared import get_mqtt_client, config, payloads
|
||||
|
||||
MQTT_CA_FILE = "/certs/ca.crt"
|
||||
|
||||
mqtt_client = get_mqtt_client(
|
||||
host=config.MQTT_BROKER_HOST,
|
||||
client_id="smartwave-esp32-" + DEVICE_ID,
|
||||
use_tls=config.USE_TLS,
|
||||
host="192.168.50.1",
|
||||
client_id="smartwave-esp32-demo",
|
||||
use_tls=True,
|
||||
cafile=MQTT_CA_FILE,
|
||||
keepalive=config.MQTT_KEEPALIVE,
|
||||
keepalive=30,
|
||||
)
|
||||
|
||||
def on_mqtt_message(message):
|
||||
print("[MQTT Thread] Received message:", message)
|
||||
|
||||
mqtt_client.set_callback(on_mqtt_message)
|
||||
# --- HARDWARE & MODULE DEFERRED IMPORTS ---
|
||||
status_led = None
|
||||
uart_device = None
|
||||
mlx_temperature_sensor = None
|
||||
cookingState = None
|
||||
log = None
|
||||
UARTCommand = None
|
||||
UARTCommandType = None
|
||||
|
||||
|
||||
def mqtt_background_thread():
|
||||
"""Background MQTT worker handling ALL socket operations safely."""
|
||||
print("[Thread] Background MQTT worker started.")
|
||||
def init_hardware():
|
||||
"""Initializes hardware peripherals AFTER MQTT TLS has reserved its RAM."""
|
||||
global status_led, uart_device, mlx_temperature_sensor
|
||||
global cookingState, log, UARTCommand, UARTCommandType
|
||||
|
||||
print("[Main] Initializing hardware peripherals...")
|
||||
|
||||
from shared import get_uart, cookingState as cs, logging
|
||||
from shared.uart_comm import UARTCommand as UC, UARTCommandType as UCT
|
||||
from shared.sensors import RGBLED
|
||||
from sensors import temperature_sensor
|
||||
|
||||
cookingState = cs
|
||||
log = logging.log
|
||||
UARTCommand = UC
|
||||
UARTCommandType = UCT
|
||||
|
||||
status_led = RGBLED(red_pin=21, green_pin=19, blue_pin=18)
|
||||
uart_device = get_uart(uart_id=2, tx_pin=17, rx_pin=16)
|
||||
|
||||
temperature_sensor_i2c = I2C(
|
||||
0,
|
||||
scl=Pin(25, Pin.IN, Pin.PULL_UP),
|
||||
sda=Pin(26, Pin.IN, Pin.PULL_UP),
|
||||
freq=100000,
|
||||
)
|
||||
devices = temperature_sensor_i2c.scan()
|
||||
if 0x5A in devices:
|
||||
print("[Main] MLX90614 found at address 0x5A!")
|
||||
else:
|
||||
print("[Main] MLX90614 not found on I2C bus.")
|
||||
mlx_temperature_sensor = temperature_sensor.MLX90614(temperature_sensor_i2c)
|
||||
|
||||
|
||||
def on_received_cooking_state_update(state, is_error=False, is_terminated=False):
|
||||
"""Callback executed when state changes are received from the LoRa board over UART."""
|
||||
if cooking_state:
|
||||
if is_error:
|
||||
cooking_state.set_state(cookingState.CookingStates.ERROR)
|
||||
elif is_terminated:
|
||||
cooking_state.set_state(cookingState.CookingStates.ABORTED)
|
||||
else:
|
||||
cooking_state.set_state(state)
|
||||
|
||||
|
||||
def on_cooking_state_change(state):
|
||||
"""Callback executed whenever local cooking state transitions."""
|
||||
BLINK_INTERVAL_MS = 500
|
||||
|
||||
if status_led and cookingState:
|
||||
if state == cookingState.CookingStates.IDLE:
|
||||
status_led.color = status_led.OFF
|
||||
status_led.blink_off()
|
||||
elif state == cookingState.CookingStates.COOKING:
|
||||
status_led.color = status_led.YELLOW
|
||||
status_led.blink_off()
|
||||
elif state == cookingState.CookingStates.STIRRING_REQUIRED:
|
||||
status_led.color = status_led.ORANGE
|
||||
status_led.blink_on(BLINK_INTERVAL_MS)
|
||||
elif state == cookingState.CookingStates.ALERT:
|
||||
status_led.color = status_led.RED
|
||||
status_led.blink_on(BLINK_INTERVAL_MS)
|
||||
elif state == cookingState.CookingStates.DONE:
|
||||
status_led.color = status_led.GREEN
|
||||
status_led.blink_off()
|
||||
|
||||
|
||||
def on_mqtt_message(message):
|
||||
"""Sync callback: Lightweight! Only updates variables or triggers async signals."""
|
||||
global orchestrator_id, cooking_state, should_unsubscribe_hello
|
||||
print("[MQTT] Received message on topic:", message.get("topic"))
|
||||
|
||||
payload_data = None
|
||||
try:
|
||||
payload_data = json.loads(message["payload"])
|
||||
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.")
|
||||
|
||||
|
||||
# --- DEDICATED ASYNC TASK FOR SENSOR PUBLISHING ---
|
||||
async def sensor_publisher_task():
|
||||
"""Waits for sensor_request_event, reads hardware, and publishes outside the MQTT lock."""
|
||||
while True:
|
||||
await sensor_request_event.wait()
|
||||
sensor_request_event.clear()
|
||||
|
||||
print("[Sensor Task] Reading temperature sensors...")
|
||||
obj_temp = (
|
||||
mlx_temperature_sensor.read_object_temp()
|
||||
if mlx_temperature_sensor
|
||||
else 0
|
||||
)
|
||||
amb_temp = (
|
||||
mlx_temperature_sensor.read_ambient_temp()
|
||||
if mlx_temperature_sensor
|
||||
else 0
|
||||
)
|
||||
|
||||
sensor_payload = payloads.mqtt_sensor_data(DEVICE_ID, obj_temp, amb_temp)
|
||||
|
||||
try:
|
||||
print("[Sensor Task] Publishing sensor data to MQTT...")
|
||||
mqtt_client.publish(
|
||||
config.MQTT_TOPIC_SENSOR, sensor_payload, qos=config.MQTT_QOS
|
||||
)
|
||||
print("[Sensor Task] Sensor data successfully published:", sensor_payload)
|
||||
except Exception as e:
|
||||
print("[Sensor Task] Failed to publish sensor data:", e)
|
||||
|
||||
|
||||
async def uart_task():
|
||||
"""Polls incoming UART messages from the LoRa board using dynamic method fallback."""
|
||||
while True:
|
||||
if uart_device:
|
||||
try:
|
||||
cmd = uart_device.read_as_command()
|
||||
|
||||
if cmd:
|
||||
print("[UART] Command received from LoRa board:", cmd)
|
||||
if (
|
||||
hasattr(cmd, "command_type")
|
||||
and cmd.command_type == UARTCommandType.STATE_UPDATE
|
||||
and on_received_cooking_state_update
|
||||
):
|
||||
on_received_cooking_state_update(
|
||||
cmd.payload.get("state"),
|
||||
cmd.payload.get("is_error", False),
|
||||
cmd.payload.get("is_terminated", False),
|
||||
)
|
||||
except Exception as e:
|
||||
print("[UART Task] Error reading command:", e)
|
||||
|
||||
await asyncio.sleep_ms(50)
|
||||
|
||||
|
||||
async def connect_mqtt_async():
|
||||
global mqtt_connected, mqtt_client
|
||||
mqtt_connected = False
|
||||
|
||||
while True:
|
||||
try:
|
||||
print("[Thread] Attempting connection to MQTT broker...")
|
||||
print("[MQTT] Connecting to broker with TLS...")
|
||||
# Re-instantiate client to clear old socket buffers
|
||||
gc.collect()
|
||||
mqtt_client = get_mqtt_client(
|
||||
host="192.168.50.1", # TODO : Use config.MQTT_BROKER_HOST instead of hardcoding
|
||||
port=8884,
|
||||
client_id="smartwave-esp32-demo",
|
||||
use_tls=True,
|
||||
cafile=MQTT_CA_FILE,
|
||||
keepalive=30,
|
||||
)
|
||||
mqtt_client.set_callback(on_mqtt_message)
|
||||
|
||||
mqtt_client.connect()
|
||||
print("[Thread] Connected! Subscribing to topic...")
|
||||
print("[MQTT] Connected! Subscribing to topics...")
|
||||
mqtt_client.subscribe(config.MQTT_TOPIC_COOKING, qos=config.MQTT_QOS)
|
||||
print("[Thread] Successfully subscribed. Setting up poller...")
|
||||
|
||||
poller = select.poll()
|
||||
poller.register(mqtt_client._client.sock, select.POLLIN)
|
||||
|
||||
last_check = time.time()
|
||||
|
||||
while True:
|
||||
# 1. Process outbound messages queued by the main thread
|
||||
while len(msg_queue) > 0:
|
||||
with queue_lock:
|
||||
topic, payload = msg_queue.pop(0)
|
||||
print(f"[Thread] Safely publishing queued message to {topic}...")
|
||||
mqtt_client.publish(topic, payload, qos=config.MQTT_QOS)
|
||||
|
||||
# 2. Check for incoming messages (non-blocking poll)
|
||||
# Shortened timeout to keep the queue responsive
|
||||
events = poller.poll(200)
|
||||
if events:
|
||||
mqtt_client.wait()
|
||||
|
||||
# 3. Handle Keepalive tracking manually
|
||||
if time.time() - last_check >= 15:
|
||||
print("[Thread] Sending keepalive ping...")
|
||||
mqtt_client._client.ping()
|
||||
last_check = time.time()
|
||||
|
||||
# Small breathe room for the CPU core
|
||||
time.sleep_ms(50)
|
||||
|
||||
mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS)
|
||||
print("[MQTT] Subscribed successfully!")
|
||||
mqtt_connected = True
|
||||
return
|
||||
except Exception as e:
|
||||
print("[Thread] Connection dropped or error encountered:", e)
|
||||
print("[Thread] Cleaning up socket context. Retrying in 5 seconds...")
|
||||
|
||||
# --- FIX FOR ERROR 23 (SOCKET LEAK) ---
|
||||
# Manually force-kill the underlying socket file descriptor if it exists
|
||||
try:
|
||||
if mqtt_client._client and hasattr(mqtt_client._client, "sock"):
|
||||
if mqtt_client._client.sock is not None:
|
||||
mqtt_client._client.sock.close()
|
||||
except Exception:
|
||||
pass # Already dead or closed
|
||||
|
||||
# Now we let the wrapper do its normal cleanup safely
|
||||
print("[MQTT] Connection failed:", e)
|
||||
sys.print_exception(e)
|
||||
try:
|
||||
mqtt_client.close()
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(5)
|
||||
|
||||
# UART
|
||||
uart_device = get_uart(uart_id=2, tx_pin=17, rx_pin=16)
|
||||
# Force heap cleanup before sleeping
|
||||
del mqtt_client
|
||||
gc.collect()
|
||||
print(f"[MQTT] Free RAM after cleanup: {gc.mem_free()} bytes")
|
||||
print("[MQTT] Retrying connection in 5 seconds...")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
# --- Launch background worker ---
|
||||
_thread.start_new_thread(mqtt_background_thread, ())
|
||||
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) ---
|
||||
print("[Main] Main execution path active.")
|
||||
time.sleep(2) # Give the thread a moment to initial connect
|
||||
async def orchestrator_hello_task():
|
||||
global mqtt_connected, should_unsubscribe_hello
|
||||
while True:
|
||||
if orchestrator_id is not None:
|
||||
if should_unsubscribe_hello:
|
||||
try:
|
||||
mqtt_client.unsubscribe(config.MQTT_TOPIC_HELLO)
|
||||
should_unsubscribe_hello = False
|
||||
print("[MQTT] Successfully unsubscribed from hello topic.")
|
||||
except Exception as e:
|
||||
print("[MQTT] Unsubscribe error:", e)
|
||||
|
||||
# Hello successfully acknowledged! Stop looping this task.
|
||||
print("[Hello Task] Orchestrator acknowledged. Stopping hello task.")
|
||||
break
|
||||
|
||||
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
|
||||
|
||||
while True:
|
||||
print("[Main] Queueing a test message for MQTT...")
|
||||
# Instead of direct publishing, push it to the queue safely
|
||||
queue_publish(config.MQTT_TOPIC_SENSOR, "Hello from ESP32!")
|
||||
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...")
|
||||
|
||||
# 1. Check if the Heltec V3 sent us something over the wire
|
||||
while uart_device.any():
|
||||
incoming_msg = uart_device.read()
|
||||
print(f"[Main] Received from esp-lora over UART: {incoming_msg}")
|
||||
|
||||
# 2. Example: Send data to the Heltec board every 5 seconds
|
||||
# uart_device.send("Status Check: WiFi Active")
|
||||
|
||||
time.sleep_ms(200) # Fast responsive polling loop for local UART
|
||||
# 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")
|
||||
PYTHON_BIN="${PYTHON_BIN:-python3}"
|
||||
PYTHON_SCRIPT="${1:-$SCRIPT_DIR/main.py}"
|
||||
REQUIREMENTS_FILE="$REPO_ROOT/requirements.txt"
|
||||
REQUIREMENTS_FILE="$SCRIPT_DIR/requirements.txt"
|
||||
|
||||
if [ ! -f "$PYTHON_SCRIPT" ]; then
|
||||
echo "Python script not found: $PYTHON_SCRIPT" >&2
|
||||
@@ -19,7 +19,7 @@ cd "$SCRIPT_DIR"
|
||||
|
||||
# 2. On lance Docker en arrière-plan
|
||||
echo "Démarrage des conteneurs Docker..."
|
||||
docker compose pull
|
||||
# docker compose pull
|
||||
docker compose up -d --remove-orphans
|
||||
|
||||
# 3. Installation des dépendances (sans '--user' si on est déjà root sous systemd)
|
||||
|
||||
+330
-87
@@ -1,116 +1,359 @@
|
||||
import threading
|
||||
import queue
|
||||
import base64
|
||||
import json
|
||||
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
|
||||
from shared.logging import log
|
||||
from shared.cookingState import CookingStates
|
||||
from shared.lora_device import LoraCommands
|
||||
from sensors import ultrasonicRanger, temp_hum, button, camera
|
||||
|
||||
# --- Read Unique Device ID ---
|
||||
try:
|
||||
with open("device_id.txt", "r") as f:
|
||||
DEVICE_ID = f.read().strip()
|
||||
except Exception:
|
||||
try:
|
||||
with open("/home/pi/SmartWave/orchestrateur/device_id.txt", "r") as f:
|
||||
DEVICE_ID = f.read().strip()
|
||||
except Exception:
|
||||
DEVICE_ID = "RPI_Orchestrateur_Default"
|
||||
def get_device_id():
|
||||
for path in ["device_id.txt", "/home/pi/SmartWave/orchestrateur/device_id.txt"]:
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
return f.read().strip()
|
||||
except Exception:
|
||||
pass
|
||||
return "RPI_Orchestrateur_Default"
|
||||
|
||||
# Thread-safe queue for application messages
|
||||
data_queue = queue.Queue()
|
||||
DEVICE_ID = get_device_id()
|
||||
|
||||
# --- STATE MACHINE DEFINITIONS ---
|
||||
class MicrowaveState:
|
||||
IDLE = "IDLE" # Microwave is empty
|
||||
ANALYZING = "ANALYZING" # Reading sensors & waiting for IR
|
||||
WAITING_FOR_CLOUD = "WAITING_FOR_CLOUD" # Waiting for API parameters
|
||||
COOKING = "COOKING" # Microwave is active
|
||||
DONE = "DONE" # Finished/Stopped, waiting for dish removal
|
||||
|
||||
# Global state trackers
|
||||
microwave_states = {"2": MicrowaveState.IDLE}
|
||||
button_state = False
|
||||
async_event_queue = None
|
||||
# Async synchronization trackers for MQTT IR sensors responses
|
||||
ir_data_cache = {} # mw_id -> dict of IR readings
|
||||
ir_data_events = {} # mw_id -> asyncio.Event()
|
||||
|
||||
|
||||
# --- HARDWARE SETUP ---
|
||||
lora = get_lora()
|
||||
lora.configure()
|
||||
|
||||
def lora_listener():
|
||||
"""Background Thread: Listens to LoRa traffic and responds to Heartbeats."""
|
||||
print("Thread Écouteur LoRa démarré.")
|
||||
while True:
|
||||
paquet = lora.receive_packet(timeout_ms=1000)
|
||||
if paquet:
|
||||
donnees = paquet["data"]
|
||||
expediteur_type = donnees.get("type")
|
||||
|
||||
if expediteur_type == deviceTypes.DEVICE_TYPES["MICROWAVE"]:
|
||||
print(f"\n[Thread LoRa] Heartbeat reçu de {donnees.get('id')}")
|
||||
reponse = {
|
||||
"id": DEVICE_ID,
|
||||
"type": deviceTypes.DEVICE_TYPES["ORCHESTRATOR"]
|
||||
}
|
||||
lora.send(reponse)
|
||||
else:
|
||||
data_queue.put({"source": "LoRa", "data": paquet})
|
||||
|
||||
# --- Setup & Connect MQTT ---
|
||||
mqtt_client = get_mqtt_client(
|
||||
host="192.168.50.1", # Using explicit gateway IP to dodge Docker loopback blocks
|
||||
client_id="smartwave-orchestrateur-"+DEVICE_ID,
|
||||
host="192.168.50.1",
|
||||
client_id="smartwave-orchestrateur-" + DEVICE_ID,
|
||||
use_tls=config.USE_TLS,
|
||||
cafile="/home/pi/SmartWave/orchestrateur/mqtt/certs/ca.crt",
|
||||
keepalive=config.MQTT_KEEPALIVE,
|
||||
)
|
||||
mqtt_client.connect()
|
||||
mqtt_client.subscribe(config.MQTT_TOPIC_SENSOR, qos=config.MQTT_QOS)
|
||||
print(f"Subscribed to topic: {config.MQTT_TOPIC_SENSOR}")
|
||||
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"):
|
||||
mqtt_client._client.loop_start()
|
||||
print("Paho MQTT asynchronous network loop started.")
|
||||
print("[MQTT] Paho background loop started.")
|
||||
|
||||
# --- BACKGROUND TASKS (PRODUCERS) ---
|
||||
async def lora_listener_task():
|
||||
"""Polls LoRa and pushes to the async queue."""
|
||||
print("[LoRa] Async listener started.")
|
||||
while True:
|
||||
# Run blocking lora receive in a thread to not block asyncio loop
|
||||
paquet = await asyncio.to_thread(lora.receive_reliable, timeout_ms=100)
|
||||
if paquet:
|
||||
await async_event_queue.put({"source": "LoRa", "data": paquet})
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
def mqtt_listener():
|
||||
"""Background Thread: Constantly inspects incoming MQTT message cache."""
|
||||
print("Thread MQTT démarré.")
|
||||
async def mqtt_listener_task():
|
||||
"""Polls MQTT cache and pushes to the async queue."""
|
||||
print("[MQTT] Async listener started.")
|
||||
while True:
|
||||
message = mqtt_client.get_message()
|
||||
|
||||
if message:
|
||||
print(f"\n[Thread MQTT] Message reçu : {message}")
|
||||
data_queue.put({"source": "MQTT", "data": message})
|
||||
|
||||
# --- THE CPU FIX ---
|
||||
# Sleep for 100ms. Prevents the thread from turning into an infinite 100% CPU hog.
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
# Launch background monitoring workers
|
||||
threading.Thread(target=lora_listener, daemon=True).start()
|
||||
threading.Thread(target=mqtt_listener, daemon=True).start()
|
||||
|
||||
print("Orchestrateur prêt. Le main loop est libre.")
|
||||
|
||||
# --- MAIN EXECUTION LOOP ---
|
||||
while True:
|
||||
try:
|
||||
# Check for non-heartbeat data safely
|
||||
try:
|
||||
msg = data_queue.get(block=False)
|
||||
print(f"\n[Main Loop] Données traitées : {msg['data']}")
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
payload = json.loads(message['payload'])
|
||||
except Exception:
|
||||
payload = message['payload']
|
||||
|
||||
time.sleep(1)
|
||||
# --- SAFE TOPIC DECODING ---
|
||||
topic = message['topic']
|
||||
if isinstance(topic, bytes):
|
||||
topic = topic.decode('utf-8')
|
||||
|
||||
await async_event_queue.put({
|
||||
"source": "MQTT",
|
||||
"topic": topic,
|
||||
"data": payload
|
||||
})
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
def button_callback():
|
||||
"""Button physical interrupt callback."""
|
||||
global button_state
|
||||
if microwave_states.get("2") == MicrowaveState.COOKING:
|
||||
print("[Button] Toggling pause/resume for microwave '2'.")
|
||||
lora.send_reliable({"id": DEVICE_ID, "microwave_id": "2", "action": LoraCommands.TOGGLE_PAUSE})
|
||||
else:
|
||||
button_state = not button_state
|
||||
print(f"[Button] Defrost state toggled to: {button_state}")
|
||||
|
||||
button.set_callback(button_callback)
|
||||
button.start_button_monitoring_thread()
|
||||
|
||||
# --- HARDWARE CONTROLLERS ---
|
||||
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:
|
||||
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}")
|
||||
|
||||
# Publish debug telemetry message
|
||||
print("[Main Loop] Envoi d'un message de debug sur MQTT...")
|
||||
response = mqtt_client.publish(
|
||||
config.MQTT_TOPIC_COOKING,
|
||||
f"Orchestrateur actif, ID: {DEVICE_ID}",
|
||||
# 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 (if it already arrived, event.wait() returns instantly)
|
||||
try:
|
||||
await asyncio.wait_for(event.wait(), timeout=10.0)
|
||||
ir_payload = ir_data_cache.get(microwave_id, {})
|
||||
sensors_data["ir_initial_temp"] = ir_payload.get("dish_temp")
|
||||
sensors_data["ir_ambient_temp"] = ir_payload.get("ambient_temp")
|
||||
print(f"[{microwave_id}] IR data synchronized successfully: {ir_payload}")
|
||||
except asyncio.TimeoutError:
|
||||
print(f"[{microwave_id}] ⚠️ Timeout waiting for MQTT IR data from ESP32.")
|
||||
sensors_data["ir_initial_temp"] = None
|
||||
sensors_data["ir_ambient_temp"] = None
|
||||
finally:
|
||||
ir_data_events.pop(microwave_id, None)
|
||||
|
||||
# 6. Dispatch cloud request task
|
||||
asyncio.create_task(request_cloud_cooking_plan(microwave_id, sensors_data))
|
||||
|
||||
async def request_cloud_cooking_plan(microwave_id, sensors_data):
|
||||
"""Sends all data to the cloud and starts the microwave if successful."""
|
||||
microwave_states[microwave_id] = MicrowaveState.WAITING_FOR_CLOUD
|
||||
URL = "https://smartwave.matthiasg.dev/cooking-params"
|
||||
|
||||
# Format image
|
||||
if isinstance(sensors_data.get("camera_image"), bytes):
|
||||
sensors_data["camera_image"] = base64.b64encode(sensors_data["camera_image"]).decode("utf-8")
|
||||
|
||||
print(f"[{microwave_id}] Requesting cooking plan from cloud app...")
|
||||
try:
|
||||
response = await asyncio.to_thread(requests.post, URL, json=sensors_data, timeout=30)
|
||||
|
||||
# Abort if state changed (e.g. user removed dish while waiting for wifi)
|
||||
if microwave_states[microwave_id] != MicrowaveState.WAITING_FOR_CLOUD:
|
||||
print(f"[{microwave_id}] Dish removed during API request. Discarding API plan.")
|
||||
return
|
||||
|
||||
response.raise_for_status()
|
||||
plan = response.json().get("cook_plan", {})
|
||||
c_time = plan.get("cook_time_seconds")
|
||||
c_power = plan.get("effective_power_watts")
|
||||
c_temp = plan.get("target_temp")
|
||||
|
||||
if c_time is None or c_power is None or c_temp is None:
|
||||
print(f"[{microwave_id}] ❌ Invalid plan received: {response.json()}")
|
||||
microwave_states[microwave_id] = MicrowaveState.DONE # Fail safe
|
||||
return
|
||||
|
||||
print(f"[{microwave_id}] Cloud Plan Received! Starting microwave: {c_time}s @ {c_power}W")
|
||||
microwave_states[microwave_id] = MicrowaveState.COOKING
|
||||
mqtt_client.publish(
|
||||
config.MQTT_TOPIC_COOKING,
|
||||
payloads.mqtt_cooking_config(microwave_id, c_time, c_power, c_temp),
|
||||
qos=config.MQTT_QOS
|
||||
)
|
||||
|
||||
# This will now unblock instantly because loop_start() handles the delivery confirmation!
|
||||
response.wait_for_publish()
|
||||
print("[Main Loop] Message de debug publié avec succès.")
|
||||
|
||||
time.sleep(9)
|
||||
|
||||
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"[{microwave_id}] Cloud API Error: {e}")
|
||||
microwave_states[microwave_id] = MicrowaveState.DONE
|
||||
|
||||
# Clean termination
|
||||
if hasattr(mqtt_client._client, "loop_stop"):
|
||||
mqtt_client._client.loop_stop()
|
||||
mqtt_client.close()
|
||||
# --- MAIN LOGIC TASKS ---
|
||||
async def process_messages_task():
|
||||
"""Consumes the unified event queue."""
|
||||
while True:
|
||||
msg = await async_event_queue.get()
|
||||
source = msg["source"]
|
||||
data = msg["data"]
|
||||
|
||||
if source == "LoRa":
|
||||
if "new_cooking_state" in data.get("data", {}):
|
||||
mw_id = data["data"].get("id")
|
||||
n_state = data["data"].get("new_cooking_state")
|
||||
print(f"[LoRa] Microwave {mw_id} state changed to: {n_state}")
|
||||
|
||||
if n_state == CookingStates.IDLE and microwave_states.get(mw_id) == MicrowaveState.COOKING:
|
||||
microwave_states[mw_id] = MicrowaveState.DONE
|
||||
print(f"[{mw_id}] Cooking finished. Waiting for user to remove dish.")
|
||||
|
||||
elif source == "MQTT":
|
||||
topic = msg["topic"]
|
||||
|
||||
# Helper to normalize config topics to str
|
||||
def to_str(val):
|
||||
return val.decode('utf-8') if isinstance(val, bytes) else val
|
||||
|
||||
hello_topic = to_str(config.MQTT_TOPIC_HELLO)
|
||||
sensor_topic = to_str(config.MQTT_TOPIC_SENSOR)
|
||||
|
||||
if topic == hello_topic:
|
||||
if data.get("id_orchestrator") != DEVICE_ID:
|
||||
mw_id = data.get("id_microwave")
|
||||
print(f"[MQTT] Hello from {mw_id}. Sending ACK.")
|
||||
mqtt_client.publish(
|
||||
config.MQTT_TOPIC_HELLO,
|
||||
payloads.mqtt_hello_ack(DEVICE_ID, mw_id),
|
||||
qos=config.MQTT_QOS
|
||||
)
|
||||
|
||||
elif topic == sensor_topic:
|
||||
mw_id = str(data.get("id_microwave"))
|
||||
print(f"[MQTT] Sensor data received for microwave {mw_id}: {data}")
|
||||
|
||||
# Store IR data and notify the waiting dish handler
|
||||
ir_data_cache[mw_id] = data
|
||||
if mw_id in ir_data_events:
|
||||
ir_data_events[mw_id].set()
|
||||
|
||||
async def get_filtered_dish_height(samples=3, delay=0.04):
|
||||
"""Reads ultrasonic sensor multiple times and returns the median, discarding invalid zeros."""
|
||||
valid_samples = []
|
||||
for _ in range(samples):
|
||||
h = await asyncio.to_thread(ultrasonicRanger.get_dish_height)
|
||||
# Discard 0.0 or near-zero timeout glitches
|
||||
if h is not None and h > 0.5:
|
||||
valid_samples.append(h)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
if valid_samples:
|
||||
valid_samples.sort()
|
||||
return valid_samples[len(valid_samples) // 2] # Median sample
|
||||
return None # All reads failed or out of range
|
||||
|
||||
|
||||
async def monitor_dish_height_task():
|
||||
"""Monitors presence of dish with hysteresis and debouncing."""
|
||||
mw_id = "2"
|
||||
consecutive_present = 0
|
||||
consecutive_absent = 0
|
||||
REQUIRED_STABLE_READS = 3 # Must see 3 stable states in a row (~1 second)
|
||||
|
||||
while True:
|
||||
dist = await get_filtered_dish_height()
|
||||
current_state = microwave_states.get(mw_id, MicrowaveState.IDLE)
|
||||
|
||||
if dist is not None:
|
||||
# Hysteresis Thresholds:
|
||||
# - Must be > 2.5 cm to detect dish insertion
|
||||
# - Must be < 1.2 cm to detect dish removal
|
||||
if dist > 2.5:
|
||||
consecutive_present += 1
|
||||
consecutive_absent = 0
|
||||
elif dist < 1.2:
|
||||
consecutive_absent += 1
|
||||
consecutive_present = 0
|
||||
else:
|
||||
# Dead-zone (1.2cm to 2.5cm) -> Noise buffer
|
||||
consecutive_present = 0
|
||||
consecutive_absent = 0
|
||||
|
||||
# --- DISH INSERTED CONFIRMED ---
|
||||
if consecutive_present >= REQUIRED_STABLE_READS and current_state == MicrowaveState.IDLE:
|
||||
consecutive_present = 0
|
||||
asyncio.create_task(handle_new_dish(mw_id, dist))
|
||||
|
||||
# --- DISH REMOVED CONFIRMED ---
|
||||
elif consecutive_absent >= REQUIRED_STABLE_READS and current_state != MicrowaveState.IDLE:
|
||||
consecutive_absent = 0
|
||||
print(f"\n[{mw_id}] Dish Removed! Resetting state to IDLE.")
|
||||
microwave_states[mw_id] = MicrowaveState.IDLE
|
||||
if current_state == MicrowaveState.COOKING:
|
||||
_stop_hardware(mw_id)
|
||||
# Remove from IR cache and events
|
||||
ir_data_cache.pop(mw_id, None)
|
||||
ir_data_events.pop(mw_id, None)
|
||||
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
# --- BOOTSTRAP ---
|
||||
async def main():
|
||||
global async_event_queue
|
||||
print("🚀 Orchestrateur Asyncio prêt. Lancement des tâches...")
|
||||
|
||||
async_event_queue = asyncio.Queue()
|
||||
|
||||
await asyncio.gather(
|
||||
lora_listener_task(),
|
||||
mqtt_listener_task(),
|
||||
process_messages_task(),
|
||||
monitor_dish_height_task()
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
print("\nArrêt manuel.")
|
||||
finally:
|
||||
if hasattr(mqtt_client._client, "loop_stop"):
|
||||
mqtt_client._client.loop_stop()
|
||||
mqtt_client.close()
|
||||
@@ -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
|
||||
@@ -7,7 +7,7 @@ lora.configure()
|
||||
print("Raspberry Pi : En attente active de JSON...")
|
||||
|
||||
while True:
|
||||
paquet = lora.receive_packet(timeout_ms=5000)
|
||||
paquet = lora.receive_reliable(timeout_ms=5000)
|
||||
if paquet:
|
||||
# Plus besoin de décoder du HEX ou de parser du JSON manuellement !
|
||||
groupe = paquet['group']
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
paho-mqtt>=1.6,<3
|
||||
pyserial>=3.5,<4
|
||||
@@ -3,6 +3,18 @@
|
||||
|
||||
import shared.deviceTypes as deviceTypes
|
||||
import shared.config as config
|
||||
import shared.payloads as payloads
|
||||
import shared.cookingState as cookingState
|
||||
import shared.safeQueue as safeQueue
|
||||
try:
|
||||
import shared.lora_device as lora_device
|
||||
except ImportError:
|
||||
pass # No need
|
||||
try:
|
||||
import shared.uart_comm as uart_comm
|
||||
except ImportError:
|
||||
pass # No need as we are on the RPI
|
||||
import shared.sensors
|
||||
|
||||
def get_lora(*args, **kwargs):
|
||||
from .lora_device import get_lora_device
|
||||
|
||||
+9
-3
@@ -1,12 +1,18 @@
|
||||
|
||||
DEBUG=True
|
||||
|
||||
# LoRa
|
||||
HEARTBEAT_INTERVAL = 10
|
||||
LORA_HEARTBEAT_INTERVAL = 30
|
||||
|
||||
# MQTT
|
||||
MQTT_BROKER_HOST = "192.168.50.1"
|
||||
MQTT_TOPIC_HELLO = b"smartwave/hello"
|
||||
MQTT_TOPIC_SENSOR = b"smartwave/sensor"
|
||||
MQTT_TOPIC_COOKING = b"smartwave/cooking"
|
||||
MQTT_KEEPALIVE = 30
|
||||
USE_TLS = True
|
||||
MQTT_QOS = 1
|
||||
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,217 @@
|
||||
import time
|
||||
|
||||
|
||||
class CookingState:
|
||||
TEMPERATURE_TOLERANCE = 1.0
|
||||
|
||||
def __init__(self, cook_time: int, power_level: int, target_temp: float, temperature_provider=None, on_state_change=None, on_refresh=None):
|
||||
self.cook_time = cook_time
|
||||
self.power_level = power_level
|
||||
self.target_temp = target_temp
|
||||
|
||||
self.start_time = time.time()
|
||||
self.temperature_provider = temperature_provider
|
||||
self.on_state_change = on_state_change
|
||||
self.on_refresh = on_refresh
|
||||
self.on_pause = None
|
||||
|
||||
self.state = CookingStates.COOKING
|
||||
self.paused = False
|
||||
self._pause_started_at = None
|
||||
self._paused_duration = 0.0
|
||||
|
||||
self.current_dish_temp = None
|
||||
self.current_ambient_temp = None
|
||||
self.estimated_remaining_time = float(cook_time)
|
||||
self._last_temperature_sample = None
|
||||
self._last_refresh_signature = None
|
||||
self._stirred = False
|
||||
|
||||
def set_temperature_provider(self, temperature_provider):
|
||||
self.temperature_provider = temperature_provider
|
||||
|
||||
def set_state_change_callback(self, callback):
|
||||
self.on_state_change = callback
|
||||
|
||||
def set_refresh_callback(self, callback):
|
||||
self.on_refresh = callback
|
||||
|
||||
def set_pause_callback(self, callback):
|
||||
self.on_pause = callback
|
||||
|
||||
def pause(self):
|
||||
if self.paused:
|
||||
return
|
||||
|
||||
self.paused = True
|
||||
self._pause_started_at = time.time()
|
||||
# self._notify_refresh(force=True)
|
||||
if self.on_pause:
|
||||
self.on_pause(self)
|
||||
def unpause(self):
|
||||
if not self.paused:
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
if self._pause_started_at is not None:
|
||||
self._paused_duration += now - self._pause_started_at
|
||||
|
||||
# self._pause_started_at = None
|
||||
self.paused = False
|
||||
# self._notify_refresh(force=True)
|
||||
|
||||
def toggle_pause(self):
|
||||
if self.paused:
|
||||
self.unpause()
|
||||
else:
|
||||
self.pause()
|
||||
self.on_pause(self)
|
||||
|
||||
def set_state(self, state):
|
||||
if self.state == state:
|
||||
return
|
||||
|
||||
self.state = state
|
||||
self._notify_state_change()
|
||||
self._notify_refresh(force=True)
|
||||
|
||||
def get_elapsed_time(self) -> float:
|
||||
now = time.time()
|
||||
elapsed = now - self.start_time - self._paused_duration
|
||||
|
||||
if self.paused and self._pause_started_at is not None:
|
||||
elapsed -= now - self._pause_started_at
|
||||
|
||||
return max(0.0, elapsed)
|
||||
|
||||
def get_remaining_time(self) -> int:
|
||||
"""Returns the estimated remaining cooking time in seconds."""
|
||||
return int(max(0.0, self.get_remaining_time_estimation()))
|
||||
|
||||
def get_remaining_time_estimation(self) -> float:
|
||||
elapsed_time = self.get_elapsed_time()
|
||||
timer_remaining = max(0.0, float(self.cook_time) - elapsed_time)
|
||||
|
||||
if self.current_dish_temp is None:
|
||||
return timer_remaining
|
||||
|
||||
if self.current_dish_temp >= self.target_temp:
|
||||
return timer_remaining
|
||||
|
||||
heating_rate = self._estimate_heating_rate()
|
||||
if heating_rate <= 0:
|
||||
return timer_remaining
|
||||
|
||||
target_remaining = (self.target_temp - self.current_dish_temp) / heating_rate
|
||||
return max(timer_remaining, max(0.0, target_remaining))
|
||||
|
||||
def _read_temperatures(self):
|
||||
if self.temperature_provider is None:
|
||||
return None, None
|
||||
|
||||
temperatures = self.temperature_provider()
|
||||
if temperatures is None:
|
||||
return None, None
|
||||
|
||||
if isinstance(temperatures, (list, tuple)) and len(temperatures) >= 2:
|
||||
return temperatures[0], temperatures[1]
|
||||
|
||||
raise ValueError("temperature_provider must return a pair: (dish_temp, ambient_temp)")
|
||||
|
||||
def _estimate_heating_rate(self):
|
||||
if self._last_temperature_sample is None:
|
||||
return 0.0
|
||||
|
||||
last_time, last_temp = self._last_temperature_sample
|
||||
now = time.time()
|
||||
current_temp = self.current_dish_temp
|
||||
|
||||
if current_temp is None:
|
||||
return 0.0
|
||||
|
||||
delta_time = now - last_time
|
||||
if delta_time <= 0:
|
||||
return 0.0
|
||||
|
||||
return (current_temp - last_temp) / delta_time
|
||||
|
||||
def _notify_state_change(self):
|
||||
if self.on_state_change is None:
|
||||
return
|
||||
|
||||
self.on_state_change(self)
|
||||
|
||||
def _notify_refresh(self, force=False):
|
||||
if self.on_refresh is None:
|
||||
return
|
||||
|
||||
signature = (
|
||||
int(self.get_elapsed_time()),
|
||||
int(self.get_remaining_time_estimation()),
|
||||
self.current_dish_temp,
|
||||
self.current_ambient_temp,
|
||||
self.state,
|
||||
self.paused,
|
||||
)
|
||||
|
||||
if not force and signature == self._last_refresh_signature:
|
||||
return
|
||||
|
||||
self._last_refresh_signature = signature
|
||||
self.on_refresh(self)
|
||||
|
||||
def update_tick(self):
|
||||
if self.state == CookingStates.IDLE:
|
||||
return self.state
|
||||
if self.paused:
|
||||
self._notify_refresh()
|
||||
return self.state
|
||||
|
||||
previous_state = self.state
|
||||
previous_temperature = self.current_dish_temp
|
||||
|
||||
try:
|
||||
self.current_dish_temp, self.current_ambient_temp = self._read_temperatures()
|
||||
except Exception:
|
||||
self.current_dish_temp = previous_temperature
|
||||
|
||||
now = time.time()
|
||||
elapsed_time = self.get_elapsed_time()
|
||||
self.estimated_remaining_time = self.get_remaining_time_estimation()
|
||||
|
||||
print(elapsed_time, self.cook_time, self.current_dish_temp, self.target_temp, self._paused_duration, self._pause_started_at, now)
|
||||
|
||||
if self.current_dish_temp is not None:
|
||||
if elapsed_time < (self.cook_time / 2.0) and self.current_dish_temp >= self.target_temp and (self._paused_duration == None or self._paused_duration < 5): # If the dish is heating too fast, we require stirring
|
||||
self.state = CookingStates.STIRRING_REQUIRED
|
||||
self.pause()
|
||||
elif elapsed_time >= self.cook_time and self.current_dish_temp >= (self.target_temp - self.TEMPERATURE_TOLERANCE):
|
||||
self.state = CookingStates.DONE
|
||||
elif elapsed_time >= self.cook_time * 1.25 and (self._paused_duration == None or self._paused_duration < 5): # If the dish is not heating up
|
||||
self.state = CookingStates.STIRRING_REQUIRED
|
||||
self.pause()
|
||||
elif self._pause_started_at != None and (self._pause_started_at + self._paused_duration) < (now - (self.cook_time * 0.75)): # If the dish had to be pause and it's been a long time, we stop the cooking
|
||||
self.state = CookingStates.DONE
|
||||
|
||||
self._last_temperature_sample = (now, self.current_dish_temp)
|
||||
|
||||
if self.state != previous_state:
|
||||
self._notify_state_change()
|
||||
|
||||
self._notify_refresh()
|
||||
return self.state
|
||||
|
||||
|
||||
class CookingStates:
|
||||
COOKING = 0
|
||||
STIRRING_REQUIRED = 1
|
||||
DONE = 2
|
||||
ALERT = 3 # Microwave is too hot internally or other alerts
|
||||
IDLE = 4 # Waiting for cooking parameters to be set, or after cooking is done
|
||||
|
||||
@staticmethod
|
||||
def get_state_name(state_val):
|
||||
for key, value in CookingStates.__dict__.items():
|
||||
if value == state_val and not key.startswith('__'):
|
||||
return key
|
||||
return "UNKNOWN"
|
||||
@@ -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}")
|
||||
+308
-57
@@ -1,5 +1,6 @@
|
||||
import sys
|
||||
import time
|
||||
import random
|
||||
|
||||
IS_MICROPYTHON = sys.implementation.name == 'micropython'
|
||||
|
||||
@@ -8,49 +9,253 @@ if IS_MICROPYTHON:
|
||||
from machine import Pin, SPI
|
||||
import ubinascii
|
||||
import ujson as json
|
||||
|
||||
else:
|
||||
import threading
|
||||
import serial
|
||||
import json
|
||||
|
||||
|
||||
# --- BASE RELIABLE LORA DEVICE ---
|
||||
class BaseLoraDevice:
|
||||
"""Base class providing automatic ACK generation, retries, and duplicate filtering."""
|
||||
|
||||
def __init__(self):
|
||||
self.processed_msg_ids = set()
|
||||
self.received_acks = set()
|
||||
self.pending_rx_queue = []
|
||||
self.default_group = 2
|
||||
|
||||
def _generate_msg_id(self):
|
||||
return random.getrandbits(16)
|
||||
|
||||
def _send_ack(self, ack_id):
|
||||
"""Sends an immediate acknowledgement packet back to the sender."""
|
||||
print(f"[ReliableLoRa] -> Triggering ACK send for msg_id: {ack_id}")
|
||||
if IS_MICROPYTHON:
|
||||
time.sleep_ms(10)
|
||||
else:
|
||||
time.sleep(0.01)
|
||||
|
||||
ack_payload = {"_type": "_ack", "_ack_id": ack_id}
|
||||
self.send(ack_payload)
|
||||
|
||||
def _process_incoming_packet(self, packet):
|
||||
"""Internal packet processor: handles ACKs and deduplication."""
|
||||
if not packet or packet.get("raw"):
|
||||
return packet
|
||||
|
||||
data = packet.get("data")
|
||||
if isinstance(data, dict):
|
||||
# 1. Handle incoming ACK response
|
||||
if data.get("_type") == "_ack":
|
||||
ack_id = data.get("_ack_id")
|
||||
print(f"[ReliableLoRa] <- SUCCESSFULLY MATCHED ACK ID: {ack_id}")
|
||||
if ack_id is not None:
|
||||
self.received_acks.add(ack_id)
|
||||
if len(self.received_acks) > 100:
|
||||
self.received_acks.clear()
|
||||
return None # Drop internal protocol message from user queue
|
||||
|
||||
# 2. Handle incoming command expecting an ACK
|
||||
msg_id = data.get("_msg_id")
|
||||
if msg_id is not None:
|
||||
print(f"[ReliableLoRa] <- Received packet with msg_id {msg_id}. Queuing ACK.")
|
||||
self._send_ack(msg_id)
|
||||
|
||||
if msg_id in self.processed_msg_ids:
|
||||
print(f"[ReliableLoRa] Discarding duplicate retry for msg_id {msg_id}")
|
||||
return None # Discard duplicate retry
|
||||
|
||||
self.processed_msg_ids.add(msg_id)
|
||||
if len(self.processed_msg_ids) > 100:
|
||||
self.processed_msg_ids.clear()
|
||||
|
||||
return packet
|
||||
|
||||
def send_reliable(self, payload, max_retries=4, ack_timeout=2.5):
|
||||
"""Sends a payload and retries until an ACK is received or max retries are reached."""
|
||||
lock = getattr(self, 'lock', None)
|
||||
|
||||
if isinstance(payload, dict):
|
||||
payload = dict(payload)
|
||||
else:
|
||||
payload = {"data": payload}
|
||||
|
||||
msg_id = self._generate_msg_id()
|
||||
payload["_msg_id"] = msg_id
|
||||
|
||||
print(f"\n[ReliableLoRa] === Starting send_reliable for msg_id {msg_id} ===")
|
||||
|
||||
for attempt in range(max_retries):
|
||||
print(f"[ReliableLoRa] Attempt {attempt + 1}/{max_retries} transmitting msg_id {msg_id}")
|
||||
self.send(payload)
|
||||
start_time = time.time()
|
||||
|
||||
while (time.time() - start_time) < ack_timeout:
|
||||
if lock: lock.acquire()
|
||||
try:
|
||||
if msg_id in self.received_acks:
|
||||
self.received_acks.remove(msg_id)
|
||||
print(f"[ReliableLoRa] === ACK received for msg_id {msg_id} on attempt {attempt + 1} ===")
|
||||
return True
|
||||
finally:
|
||||
if lock: lock.release()
|
||||
|
||||
packet = self.receive_packet(timeout_ms=500)
|
||||
if packet:
|
||||
print(f"[ReliableLoRa] Received raw packet while waiting for ACK: {packet}")
|
||||
if lock: lock.acquire()
|
||||
try:
|
||||
filtered_packet = self._process_incoming_packet(packet)
|
||||
if filtered_packet:
|
||||
self.pending_rx_queue.append(filtered_packet)
|
||||
finally:
|
||||
if lock: lock.release()
|
||||
|
||||
if lock: lock.acquire()
|
||||
try:
|
||||
if msg_id in self.received_acks:
|
||||
self.received_acks.remove(msg_id)
|
||||
print(f"[ReliableLoRa] === ACK received for msg_id {msg_id} after poll ===")
|
||||
return True
|
||||
finally:
|
||||
if lock: lock.release()
|
||||
|
||||
print(f"[ReliableLoRa] Attempt {attempt + 1} timed out waiting for ACK for msg_id {msg_id}")
|
||||
|
||||
print(f"[ReliableLoRa] ERROR: Failed to receive ACK for msg_id {msg_id} after {max_retries} attempts.")
|
||||
return False
|
||||
|
||||
def receive_reliable(self, timeout_ms=1000):
|
||||
"""Receives a packet, automatically sending ACKs and filtering duplicate retries."""
|
||||
if len(self.pending_rx_queue) > 0:
|
||||
return self.pending_rx_queue.pop(0)
|
||||
|
||||
start_time = time.time()
|
||||
timeout_s = timeout_ms / 1000.0
|
||||
|
||||
while True:
|
||||
elapsed = time.time() - start_time
|
||||
remaining_ms = int((timeout_s - elapsed) * 1000)
|
||||
if remaining_ms <= 0:
|
||||
break
|
||||
|
||||
poll_time = max(50, min(remaining_ms, 300))
|
||||
packet = self.receive_packet(timeout_ms=poll_time)
|
||||
if packet:
|
||||
filtered_packet = self._process_incoming_packet(packet)
|
||||
if filtered_packet:
|
||||
return filtered_packet
|
||||
|
||||
return None
|
||||
|
||||
|
||||
if IS_MICROPYTHON:
|
||||
# --- PILOTE SPI DIRECT (ESP32 / Heltec V3) ---
|
||||
class LoraHardwareSPI:
|
||||
class LoraHardwareSPI(BaseLoraDevice):
|
||||
def __init__(self, spi_bus=1, clk=9, mosi=10, miso=11, cs=8, irq=14, rst=12, gpio=13):
|
||||
from sx1262 import SX1262
|
||||
self.lora = SX1262(
|
||||
spi_bus=spi_bus, clk=clk, mosi=mosi, miso=miso,
|
||||
cs=cs, irq=irq, rst=rst, gpio=gpio
|
||||
)
|
||||
self.default_group = 2 # On définit le groupe par défaut ici
|
||||
self.lock = _thread.allocate_lock() # Création du verrou
|
||||
super().__init__()
|
||||
self._pins = {
|
||||
"spi_bus": spi_bus, "clk": clk, "mosi": mosi, "miso": miso,
|
||||
"cs": cs, "irq": irq, "rst": rst, "gpio": gpio
|
||||
}
|
||||
self._cfg = {"freq": 868.1, "bw": 125.0, "sf": 7, "cr": 5, "power": 14}
|
||||
self.lock = _thread.allocate_lock()
|
||||
self.lora = None
|
||||
self.reset_hardware()
|
||||
|
||||
def reset_hardware(self):
|
||||
"""Resets SX1262 hardware and recreates driver instance."""
|
||||
with self.lock:
|
||||
try:
|
||||
irq_pin = Pin(self._pins["irq"], Pin.IN)
|
||||
irq_pin.irq(handler=None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
rst_pin = Pin(self._pins["rst"], Pin.OUT)
|
||||
rst_pin.value(0)
|
||||
time.sleep_ms(30)
|
||||
rst_pin.value(1)
|
||||
time.sleep_ms(50)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.lora = None
|
||||
time.sleep_ms(50)
|
||||
|
||||
try:
|
||||
from sx1262 import SX1262
|
||||
new_instance = SX1262(**self._pins)
|
||||
new_instance.begin(
|
||||
freq=self._cfg["freq"], bw=self._cfg["bw"], sf=self._cfg["sf"],
|
||||
cr=self._cfg["cr"], power=self._cfg["power"],
|
||||
useRegulatorLDO=False, crcOn=True, preambleLength=8, implicit=False
|
||||
)
|
||||
# SyncWord 0x12 = Decimal 18
|
||||
new_instance.setSyncWord(0x12)
|
||||
self.lora = new_instance
|
||||
except Exception as e:
|
||||
print(f"[LoRa SPI] Initialization error: {e}")
|
||||
|
||||
def configure(self, freq=868.1, bw=125.0, sf=7, cr=5, power=14):
|
||||
self.lora.begin(
|
||||
freq=freq, bw=bw, sf=sf, cr=cr, power=power,
|
||||
useRegulatorLDO=False, crcOn=True, preambleLength=8, implicit=False
|
||||
)
|
||||
self.lora.setSyncWord(0x14)
|
||||
self._cfg = {"freq": freq, "bw": bw, "sf": sf, "cr": cr, "power": power}
|
||||
if self.lora is None:
|
||||
self.reset_hardware()
|
||||
else:
|
||||
with self.lock:
|
||||
try:
|
||||
self.lora.begin(
|
||||
freq=freq, bw=bw, sf=sf, cr=cr, power=power,
|
||||
useRegulatorLDO=False, crcOn=True, preambleLength=8, implicit=False
|
||||
)
|
||||
self.lora.setSyncWord(0x12)
|
||||
except Exception:
|
||||
self.reset_hardware()
|
||||
|
||||
def send(self, payload, group=None):
|
||||
"""Encode la payload en JSON si nécessaire, et injecte automatiquement l'octet de groupe."""
|
||||
"""Encodes payload into JSON and prepends group byte."""
|
||||
with self.lock:
|
||||
if self.lora is None:
|
||||
return
|
||||
|
||||
if group is None:
|
||||
group = self.default_group
|
||||
|
||||
# Si c'est un dictionnaire ou une liste, on le convertit en JSON textuel
|
||||
if isinstance(payload, (dict, list)):
|
||||
payload = json.dumps(payload)
|
||||
|
||||
if isinstance(payload, str):
|
||||
payload = payload.encode('utf-8')
|
||||
|
||||
# Insertion automatique de l'octet de groupe au tout début de la trame physique
|
||||
paquet_physique = bytes([group]) + payload
|
||||
self.lora.send(paquet_physique)
|
||||
try:
|
||||
self.lora.send(paquet_physique)
|
||||
except Exception as e:
|
||||
print(f"[LoRa SPI] Send error: {e}")
|
||||
|
||||
def receive_packet(self, timeout_ms=1000):
|
||||
"""Écoute, nettoie, extrait le groupe, gère le HEX et parse le JSON."""
|
||||
def receive_packet(self, timeout_ms=500):
|
||||
"""Listens on SPI bus with auto-detection for JSON vs. Grouped headers."""
|
||||
with self.lock:
|
||||
data, state = self.lora.recv(len=0, timeout_en=True, timeout_ms=timeout_ms)
|
||||
if state == 0 and len(data) > 1:
|
||||
group = data[0]
|
||||
payload_brute = data[1:].strip(b'\x00 \r\n\t')
|
||||
if self.lora is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
data, state = self.lora.recv(len=0, timeout_en=True, timeout_ms=timeout_ms)
|
||||
except Exception as e:
|
||||
print(f"[LoRa SPI] Recv error caught: {e}")
|
||||
return None
|
||||
|
||||
if state == 0 and data is not None and len(data) > 0:
|
||||
if data[0] in (0x7B, 0x5B): # Starts with '{' or '['
|
||||
group = self.default_group
|
||||
payload_brute = data.strip(b'\x00 \r\n\t')
|
||||
elif len(data) > 1:
|
||||
group = data[0]
|
||||
payload_brute = data[1:].strip(b'\x00 \r\n\t')
|
||||
else:
|
||||
return None
|
||||
|
||||
try:
|
||||
text = payload_brute.decode('utf-8').strip('\x00 \r\n\t')
|
||||
@@ -76,13 +281,10 @@ if IS_MICROPYTHON:
|
||||
return None
|
||||
|
||||
else:
|
||||
import threading
|
||||
import serial
|
||||
import json
|
||||
|
||||
# --- PILOTE SÉRIE (Raspberry Pi / Dragino LA66) ---
|
||||
class LoraSerialAT:
|
||||
class LoraSerialAT(BaseLoraDevice):
|
||||
def __init__(self, port):
|
||||
super().__init__()
|
||||
self.port = port
|
||||
self.ser = serial.Serial(
|
||||
port=self.port,
|
||||
@@ -95,37 +297,60 @@ else:
|
||||
self.ser.reset_input_buffer()
|
||||
self.ser.reset_output_buffer()
|
||||
self.lock = threading.Lock()
|
||||
def configure(self, **kwargs):
|
||||
pass
|
||||
|
||||
# Initial configuration
|
||||
self.configure(freq=868.1, sf=7, bw=125)
|
||||
|
||||
def send(self, payload):
|
||||
"""Encode automatiquement la payload en HEX pour l'envoi via la clé."""
|
||||
def _send_at_cmd(self, cmd, wait_time=0.15):
|
||||
"""Helper to send AT command and purge response buffer."""
|
||||
self.ser.write(f"{cmd}\r\n".encode('utf-8'))
|
||||
time.sleep(wait_time)
|
||||
resp = ""
|
||||
while self.ser.in_waiting > 0:
|
||||
resp += self.ser.readline().decode('utf-8', errors='ignore')
|
||||
return resp
|
||||
|
||||
def configure(self, freq=868.1, sf=7, bw=125):
|
||||
"""Configures LA66 frequency, SF, BW, SyncWord, CRC, and continuous RX mode."""
|
||||
with self.lock:
|
||||
freq_hz = int(freq * 1000000)
|
||||
bw_code = 0 if bw == 125 else 1
|
||||
|
||||
# Parameters: Freq, SF, BW, CR(0=4/5), Preamble(8), Header(1=Explicit), CRC(1=ON), IQ(0=Standard), NetMode(0=P2P), Power(14), SyncWord(18=0x12), Format(0), Type(1)
|
||||
at_cfg_cmd = f"AT+CFG={freq_hz},{sf},{bw_code},0,8,1,1,0,0,14,18,0,1"
|
||||
self._send_at_cmd(at_cfg_cmd, wait_time=0.2)
|
||||
|
||||
# Fallback standalone commands
|
||||
self._send_at_cmd("AT+SYNCWORD=18", wait_time=0.1)
|
||||
self._send_at_cmd("AT+PRECV=65535", wait_time=0.1)
|
||||
self.ser.reset_input_buffer()
|
||||
|
||||
def send(self, payload, group=None):
|
||||
"""Encodes payload into HEX AT command and re-enables continuous RX."""
|
||||
with self.lock:
|
||||
if group is None:
|
||||
group = self.default_group
|
||||
|
||||
if isinstance(payload, (dict, list)):
|
||||
payload = json.dumps(payload)
|
||||
|
||||
if isinstance(payload, str):
|
||||
payload = payload.encode('utf-8')
|
||||
|
||||
hex_payload = payload.hex()
|
||||
paquet_physique = bytes([group]) + payload
|
||||
hex_payload = paquet_physique.hex()
|
||||
self.ser.reset_input_buffer()
|
||||
|
||||
# La clé ajoute d'elle-même l'octet de groupe configuré dans ses registres
|
||||
cmd = f"AT+SEND=1,{hex_payload},1,3\r\n"
|
||||
# print(f"RPI : Envoi de la commande HEX -> AT+SEND=1,[HEX_DATA],1,3")
|
||||
self.ser.write(cmd.encode('utf-8'))
|
||||
print(f"[RPi LoRa Serial] Transmitting HEX payload: {hex_payload}")
|
||||
cmd = f"AT+PSEND={hex_payload}"
|
||||
resp = self._send_at_cmd(cmd, wait_time=0.25) # Wait for RF TX to finish
|
||||
print(f"[RPi LoRa Serial] AT+PSEND response: {resp}")
|
||||
|
||||
time.sleep(0.2)
|
||||
response = ""
|
||||
start_wait = time.time()
|
||||
while (time.time() - start_wait) < 1.5:
|
||||
if self.ser.in_waiting > 0:
|
||||
response += self.ser.readline().decode('utf-8', errors='ignore')
|
||||
time.sleep(0.05)
|
||||
|
||||
# print(f"[RPI LA66 TX STATUS] :\n{response.strip()}")
|
||||
# Re-enable continuous receive mode after transmission completes
|
||||
self._send_at_cmd("AT+PRECV=65535", wait_time=0.05)
|
||||
|
||||
def receive_packet(self, timeout_ms=5000):
|
||||
def receive_packet(self, timeout_ms=500):
|
||||
"""Reads incoming serial lines from LA66 stick with robust format parsing."""
|
||||
with self.lock:
|
||||
start_time = time.time()
|
||||
timeout_s = timeout_ms / 1000.0
|
||||
@@ -136,18 +361,38 @@ else:
|
||||
if line:
|
||||
payload_bytes = None
|
||||
|
||||
if "(HEX:)" in line:
|
||||
# Robust parsing for LA66 response variants (+RECV:, +RCV=, +DRX:, HEX:, Data:)
|
||||
if "+RECV:" in line:
|
||||
parts = line.split("+RECV:")[1].strip().split(",")
|
||||
hex_str = parts[2].strip() if len(parts) >= 3 else parts[0].strip()
|
||||
try: payload_bytes = bytes.fromhex(hex_str)
|
||||
except ValueError: pass
|
||||
elif "+RCV=" in line:
|
||||
parts = line.split("+RCV=")[1].strip().split(",")
|
||||
if len(parts) >= 4:
|
||||
try: payload_bytes = bytes.fromhex(parts[3].strip())
|
||||
except ValueError: pass
|
||||
elif "+DRX:" in line:
|
||||
parts = line.split("+DRX:")[1].strip().split(",")
|
||||
if len(parts) >= 2:
|
||||
try: payload_bytes = bytes.fromhex(parts[1].strip())
|
||||
except ValueError: pass
|
||||
elif "(HEX:)" in line:
|
||||
hex_part = line.split("(HEX:)")[1].strip().replace(" ", "")
|
||||
try:
|
||||
payload_bytes = bytes.fromhex(hex_part)
|
||||
except ValueError:
|
||||
pass
|
||||
try: payload_bytes = bytes.fromhex(hex_part)
|
||||
except ValueError: pass
|
||||
elif "Data:" in line:
|
||||
payload_bytes = line.split("Data:")[1].strip().encode('utf-8')
|
||||
|
||||
if payload_bytes and len(payload_bytes) > 1:
|
||||
group = payload_bytes[0]
|
||||
payload_clean = payload_bytes[1:].strip(b'\x00 \r\n\t')
|
||||
if payload_bytes and len(payload_bytes) > 0:
|
||||
if payload_bytes[0] in (0x7B, 0x5B):
|
||||
group = self.default_group
|
||||
payload_clean = payload_bytes.strip(b'\x00 \r\n\t')
|
||||
elif len(payload_bytes) > 1:
|
||||
group = payload_bytes[0]
|
||||
payload_clean = payload_bytes[1:].strip(b'\x00 \r\n\t')
|
||||
else:
|
||||
continue
|
||||
|
||||
try:
|
||||
text = payload_clean.decode('utf-8').strip('\x00 \r\n\t')
|
||||
@@ -180,4 +425,10 @@ def get_lora_device(port_or_pins=None):
|
||||
return LoraHardwareSPI(**pins)
|
||||
else:
|
||||
port = port_or_pins if port_or_pins else "/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0"
|
||||
return LoraSerialAT(port)
|
||||
return LoraSerialAT(port)
|
||||
|
||||
|
||||
class LoraCommands:
|
||||
PING = "ping"
|
||||
COOKING_STATE_UPDATE = "cooking_state_update"
|
||||
TOGGLE_PAUSE = "toggle_pause"
|
||||
+129
-25
@@ -11,15 +11,12 @@ try:
|
||||
except ImportError:
|
||||
try:
|
||||
from umqtt.simple import MQTTClient as _MQTTClient
|
||||
import _thread
|
||||
import gc
|
||||
BACKEND_NAME = "umqtt.simple"
|
||||
IS_MICROPYTHON = True
|
||||
except ImportError:
|
||||
try:
|
||||
from umqtt.robust import MQTTClient as _MQTTClient
|
||||
BACKEND_NAME = "umqtt.robust"
|
||||
IS_MICROPYTHON = True
|
||||
except ImportError as exc:
|
||||
raise ImportError("No MQTT client found. Expected paho.mqtt or umqtt.") from exc
|
||||
except ImportError as exc:
|
||||
raise ImportError("No MQTT client found. Expected paho.mqtt or umqtt.") from exc
|
||||
|
||||
|
||||
DEFAULT_PORT = 8884
|
||||
@@ -79,6 +76,11 @@ class BrokerClient:
|
||||
self._client = None
|
||||
self._callback = None
|
||||
self._messages = []
|
||||
self._cadata = None # Cache cert bytes to prevent heap fragmentation
|
||||
|
||||
# Thread safety lock for MicroPython socket reads/writes
|
||||
if IS_MICROPYTHON:
|
||||
self._lock = _thread.allocate_lock()
|
||||
|
||||
def set_callback(self, callback):
|
||||
self._callback = callback
|
||||
@@ -104,16 +106,24 @@ class BrokerClient:
|
||||
return self._client
|
||||
|
||||
if IS_MICROPYTHON:
|
||||
gc.collect() # Clean Python heap before importing/allocating SSL
|
||||
import ssl
|
||||
ssl_params = self.ssl_params
|
||||
|
||||
if self.use_tls and ssl_params is None:
|
||||
# MicroPython uses context-less structures.
|
||||
# If your CA is self-signed, validation can fail without a valid hostname match.
|
||||
# OPTION A: If broker uses 'require_certificate false' and self-signed certs:
|
||||
# Do NOT pass cadata when cert_reqs is CERT_NONE to save ~20KB of C-DRAM
|
||||
ssl_params = {
|
||||
"cert_reqs": ssl.CERT_NONE, # Temporarily change to NONE to test if validation is the culprit
|
||||
"cadata": _read_file_bytes(self.cafile)
|
||||
"cert_reqs": ssl.CERT_NONE,
|
||||
"server_hostname": self.host
|
||||
}
|
||||
|
||||
# OPTION B: If strict CA validation IS required, load cadata ONLY with CERT_REQUIRED:
|
||||
# ssl_params = {
|
||||
# "cert_reqs": ssl.CERT_REQUIRED,
|
||||
# "cadata": _read_file_bytes(self.cafile),
|
||||
# "server_hostname": self.host
|
||||
# }
|
||||
|
||||
client = _MQTTClient(
|
||||
self.client_id or "smartWave-client",
|
||||
@@ -150,19 +160,35 @@ class BrokerClient:
|
||||
return self._client
|
||||
|
||||
def connect(self):
|
||||
client = self.open()
|
||||
if IS_MICROPYTHON:
|
||||
client.connect()
|
||||
return client
|
||||
gc.collect() # Force C & Python memory cleanup right before TLS handshake
|
||||
|
||||
client.connect(self.host, self.port, self.keepalive)
|
||||
return client
|
||||
if self._client is not None:
|
||||
self.close()
|
||||
|
||||
client = self.open()
|
||||
|
||||
try:
|
||||
if IS_MICROPYTHON:
|
||||
gc.collect() # Sweep memory right before umqtt calls ssl.wrap_socket()
|
||||
with self._lock:
|
||||
client.connect()
|
||||
return client
|
||||
|
||||
client.connect(self.host, self.port, self.keepalive)
|
||||
return client
|
||||
except Exception as e:
|
||||
print("MQTT connection failed, closing client and releasing memory.")
|
||||
print("Exception:", e)
|
||||
self.close()
|
||||
raise
|
||||
|
||||
def publish(self, topic, payload, qos=2, retain=False):
|
||||
client = self.open()
|
||||
payload_bytes = _ensure_bytes(payload)
|
||||
if IS_MICROPYTHON:
|
||||
return client.publish(topic, payload_bytes, retain=retain, qos=qos)
|
||||
with self._lock:
|
||||
return client.publish(topic, payload_bytes, retain=retain, qos=qos)
|
||||
|
||||
if isinstance(topic, bytes):
|
||||
topic = topic.decode('utf-8')
|
||||
@@ -172,13 +198,62 @@ class BrokerClient:
|
||||
def subscribe(self, topic, qos=2):
|
||||
client = self.open()
|
||||
if IS_MICROPYTHON:
|
||||
client.set_callback(self._on_micropython_message)
|
||||
return client.subscribe(topic, qos=qos)
|
||||
with self._lock:
|
||||
client.set_callback(self._on_micropython_message)
|
||||
return client.subscribe(topic, qos=qos)
|
||||
|
||||
if isinstance(topic, bytes):
|
||||
topic = topic.decode('utf-8')
|
||||
|
||||
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):
|
||||
self._store_message(topic, payload, None, False)
|
||||
@@ -187,14 +262,16 @@ class BrokerClient:
|
||||
if self._client is None:
|
||||
return None
|
||||
if IS_MICROPYTHON:
|
||||
return self._client.check_msg()
|
||||
with self._lock:
|
||||
return self._client.check_msg()
|
||||
return self._client.loop(timeout=timeout)
|
||||
|
||||
def wait(self):
|
||||
if self._client is None:
|
||||
return None
|
||||
if IS_MICROPYTHON:
|
||||
return self._client.wait_msg()
|
||||
with self._lock:
|
||||
return self._client.wait_msg()
|
||||
return self._client.loop_forever()
|
||||
|
||||
def get_message(self):
|
||||
@@ -203,13 +280,40 @@ class BrokerClient:
|
||||
return self._messages.pop(0)
|
||||
|
||||
def close(self):
|
||||
"""Safely clean up socket context without causing ESP32 C panics."""
|
||||
if self._client is None:
|
||||
return
|
||||
try:
|
||||
self._client.disconnect()
|
||||
except Exception:
|
||||
|
||||
if IS_MICROPYTHON:
|
||||
with self._lock:
|
||||
try:
|
||||
if hasattr(self._client, "sock") and self._client.sock:
|
||||
self._client.sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
if hasattr(self._client, "sock"):
|
||||
self._client.sock = None
|
||||
self._client = None
|
||||
gc.collect() # Immediately reclaim freed socket & mbedTLS RAM
|
||||
else:
|
||||
try:
|
||||
self._client.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self._client = None
|
||||
|
||||
def ping(self):
|
||||
"""Thread-safe PINGREQ wrapper for MicroPython."""
|
||||
if self._client is None:
|
||||
return
|
||||
if IS_MICROPYTHON:
|
||||
with self._lock:
|
||||
return self._client.ping()
|
||||
else:
|
||||
# Paho handles keepalives automatically via loop_start/loop
|
||||
pass
|
||||
self._client = None
|
||||
|
||||
def __enter__(self):
|
||||
self.connect()
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
from time import time
|
||||
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
def mqtt_hello_ack(id_orchestrator, id_microwave):
|
||||
return as_json({
|
||||
"id_microwave": id_microwave,
|
||||
"id_orchestrator": id_orchestrator
|
||||
})
|
||||
|
||||
def mqtt_cooking_init(id_microwave):
|
||||
return as_json({
|
||||
"id_microwave": id_microwave
|
||||
})
|
||||
|
||||
def mqtt_sensor_data(id_microwave, dish_temp, ambient_temp):
|
||||
return as_json({
|
||||
"id_microwave": id_microwave,
|
||||
"dish_temp": dish_temp,
|
||||
"ambient_temp": ambient_temp
|
||||
})
|
||||
|
||||
def mqtt_cooking_config(id_microwave, cook_time, power_level, target_temp):
|
||||
return as_json({
|
||||
"id_microwave": id_microwave,
|
||||
"cook_time": cook_time,
|
||||
"power_level": power_level,
|
||||
"target_temp": target_temp
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import _thread
|
||||
|
||||
class SafeQueue:
|
||||
"""A lightweight, thread-safe FIFO queue for MicroPython."""
|
||||
def __init__(self, maxsize=20):
|
||||
self._queue = []
|
||||
self._lock = _thread.allocate_lock()
|
||||
self.maxsize = maxsize
|
||||
|
||||
def put(self, item) -> bool:
|
||||
"""Push an item to the end of the queue. Returns False if queue is full."""
|
||||
with self._lock:
|
||||
if len(self._queue) < self.maxsize:
|
||||
self._queue.append(item)
|
||||
return True
|
||||
else:
|
||||
print("[Queue Warning] Buffer full, dropping oldest message.")
|
||||
self._queue.pop(0) # Drop oldest to make room
|
||||
self._queue.append(item)
|
||||
return False
|
||||
|
||||
def get(self):
|
||||
"""Pop and return the oldest item from the queue, or None if empty."""
|
||||
with self._lock:
|
||||
if self._queue:
|
||||
return self._queue.pop(0)
|
||||
return None
|
||||
|
||||
def empty(self) -> bool:
|
||||
"""Check if the queue has no items."""
|
||||
with self._lock:
|
||||
return len(self._queue) == 0
|
||||
|
||||
def size(self) -> int:
|
||||
"""Return current number of queued items."""
|
||||
with self._lock:
|
||||
return len(self._queue)
|
||||
@@ -0,0 +1,4 @@
|
||||
try:
|
||||
from shared.sensors.rgb_led import RGBLED
|
||||
except ImportError:
|
||||
pass # No need as we are on the RPI
|
||||
@@ -0,0 +1,159 @@
|
||||
from machine import Pin, PWM, Timer
|
||||
import time
|
||||
|
||||
class RGBLED:
|
||||
"""
|
||||
MicroPython driver for 4-pin RGB LEDs on ESP32 / Heltec boards.
|
||||
Supports state tracking, color setting, brightness scaling,
|
||||
state toggling, and non-blocking blinking via machine.Timer.
|
||||
"""
|
||||
|
||||
RED = (255, 0, 0)
|
||||
GREEN = (0, 255, 0)
|
||||
BLUE = (0, 0, 255)
|
||||
YELLOW = (255, 120, 0)
|
||||
WHITE_YELLOW = (150, 30, 0)
|
||||
ORANGE = (255, 50, 0)
|
||||
WHITE = (255, 255, 255)
|
||||
OFF = (0, 0, 0)
|
||||
|
||||
def __init__(self, red_pin, green_pin, blue_pin, common_anode=False, freq=1000, timer_id=1):
|
||||
"""
|
||||
:param red_pin: GPIO pin number for Red channel
|
||||
:param green_pin: GPIO pin number for Green channel
|
||||
:param blue_pin: GPIO pin number for Blue channel
|
||||
:param common_anode: Set True if cathode is connected to 3.3V instead of GND
|
||||
:param freq: PWM frequency in Hz (default 1000Hz)
|
||||
:param timer_id: Hardware/software timer ID for non-blocking blinks (-1 uses soft timers on ESP32).
|
||||
"""
|
||||
self._r_pwm = PWM(Pin(red_pin, Pin.OUT), freq=freq)
|
||||
self._g_pwm = PWM(Pin(green_pin, Pin.OUT), freq=freq)
|
||||
self._b_pwm = PWM(Pin(blue_pin, Pin.OUT), freq=freq)
|
||||
|
||||
self._common_anode = common_anode
|
||||
|
||||
# State tracking variables
|
||||
self._color = (0, 0, 0) # Current (R, G, B) tuple [0-255]
|
||||
self._brightness = 1.0 # Brightness factor [0.0 to 1.0]
|
||||
self._is_on = True # Master power state
|
||||
|
||||
# Blink state variables
|
||||
self._timer = Timer(timer_id)
|
||||
self._is_blinking = False
|
||||
|
||||
self._apply()
|
||||
|
||||
def _apply(self):
|
||||
"""Recalculates and applies PWM duty cycles based on state."""
|
||||
if not self._is_on:
|
||||
r, g, b = 0, 0, 0
|
||||
else:
|
||||
r = int(self._color[0] * self._brightness)
|
||||
g = int(self._color[1] * self._brightness)
|
||||
b = int(self._color[2] * self._brightness)
|
||||
|
||||
for pwm, val in ((self._r_pwm, r), (self._g_pwm, g), (self._b_pwm, b)):
|
||||
# Clamp value between 0 and 255
|
||||
val = max(0, min(255, val))
|
||||
# Convert 8-bit (0-255) to MicroPython's 16-bit PWM duty (0-65535)
|
||||
duty = int((val / 255.0) * 65535)
|
||||
|
||||
if self._common_anode:
|
||||
duty = 65535 - duty
|
||||
|
||||
pwm.duty_u16(duty)
|
||||
|
||||
# --- Properties and Setters ---
|
||||
|
||||
@property
|
||||
def color(self):
|
||||
"""Returns the active RGB tuple (R, G, B)."""
|
||||
return self._color
|
||||
|
||||
@color.setter
|
||||
def color(self, rgb_tuple):
|
||||
"""Sets the RGB color tuple (e.g., (255, 128, 0))."""
|
||||
if isinstance(rgb_tuple, (tuple, list)) and len(rgb_tuple) == 3:
|
||||
self._color = tuple(rgb_tuple)
|
||||
self._apply()
|
||||
else:
|
||||
raise ValueError("Color must be a tuple of 3 integers: (R, G, B)")
|
||||
|
||||
@property
|
||||
def brightness(self):
|
||||
"""Returns the current brightness level (0.0 to 1.0)."""
|
||||
return self._brightness
|
||||
|
||||
@brightness.setter
|
||||
def brightness(self, level):
|
||||
"""Sets brightness level from 0.0 (0%) to 1.0 (100%)."""
|
||||
self._brightness = max(0.0, min(1.0, float(level)))
|
||||
self._apply()
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Returns True if the LED is currently powered on."""
|
||||
return self._is_on
|
||||
|
||||
@property
|
||||
def is_blinking(self):
|
||||
return self._is_blinking
|
||||
|
||||
# --- Basic Control Methods ---
|
||||
|
||||
def set_rgb(self, r, g, b):
|
||||
"""Alternative setter for individual R, G, B integer values."""
|
||||
self.color = (r, g, b)
|
||||
|
||||
def on(self):
|
||||
"""Turns the LED on using its stored color and brightness."""
|
||||
self._is_on = True
|
||||
self._apply()
|
||||
|
||||
def off(self):
|
||||
"""Turns the LED off without resetting the active color state."""
|
||||
self._is_on = False
|
||||
self._apply()
|
||||
|
||||
def toggle(self):
|
||||
"""Toggles between ON and OFF states."""
|
||||
self._is_on = not self._is_on
|
||||
self._apply()
|
||||
|
||||
# --- Non-Blocking Blinking Methods ---
|
||||
|
||||
def _timer_callback(self, t):
|
||||
"""Internal callback executed by machine.Timer."""
|
||||
self.toggle()
|
||||
|
||||
def blink_on(self, interval_ms=500):
|
||||
"""Starts background blinking at the specified interval in milliseconds."""
|
||||
if self._is_blinking:
|
||||
self._timer.deinit()
|
||||
|
||||
self._is_blinking = True
|
||||
self.on() # Ensure initial state is on
|
||||
self._timer.init(
|
||||
period=interval_ms,
|
||||
mode=Timer.PERIODIC,
|
||||
callback=self._timer_callback
|
||||
)
|
||||
|
||||
def blink_off(self):
|
||||
"""Stops blinking and returns control to steady state."""
|
||||
if self._is_blinking:
|
||||
self._timer.deinit()
|
||||
self._is_blinking = False
|
||||
|
||||
def blink_toggle(self, interval_ms=500):
|
||||
"""Toggles blinking state (starts if stopped, stops if active)."""
|
||||
if self._is_blinking:
|
||||
self.blink_off()
|
||||
else:
|
||||
self.blink_on(interval_ms)
|
||||
|
||||
def deinit(self):
|
||||
"""Releases the hardware PWM pins and timer when finished."""
|
||||
self._r_pwm.deinit()
|
||||
self._g_pwm.deinit()
|
||||
self._b_pwm.deinit()
|
||||
+74
-40
@@ -1,66 +1,100 @@
|
||||
# shared/uart_comm.py
|
||||
import _thread
|
||||
from machine import UART
|
||||
import time
|
||||
import ujson
|
||||
|
||||
class SafeUART:
|
||||
def __init__(self, uart_id, tx_pin, rx_pin, baudrate=115200):
|
||||
# Initialize the hardware UART channel
|
||||
self.uart = UART(uart_id, baudrate=baudrate, tx=tx_pin, rx=rx_pin, timeout=10)
|
||||
|
||||
# Core thread-safety assets
|
||||
# Setting timeout allows readline() to be non-blocking
|
||||
self.uart = UART(uart_id, baudrate=baudrate, tx=tx_pin, rx=rx_pin, timeout=10, rxbuf=1024)
|
||||
self.lock = _thread.allocate_lock()
|
||||
self.rx_queue = []
|
||||
self.buffer = b""
|
||||
|
||||
# Start the background data worker thread
|
||||
_thread.stack_size(4096) # Cap the stack size for the UART listener
|
||||
_thread.stack_size(4096)
|
||||
_thread.start_new_thread(self._listener_worker, ())
|
||||
_thread.stack_size(0)
|
||||
|
||||
print(f"[UART] Thread initialized on UART{uart_id} (TX:{tx_pin}, RX:{rx_pin})")
|
||||
_thread.stack_size(0)
|
||||
|
||||
def _listener_worker(self):
|
||||
"""Asynchronous internal loop parsing incoming stream lines into the queue."""
|
||||
"""Simple worker that relies on newline framing instead of manual JSON parsing."""
|
||||
while True:
|
||||
try:
|
||||
if self.uart.any():
|
||||
with self.lock:
|
||||
# Pull all raw bytes waiting in the hardware ring buffer
|
||||
chunk = self.uart.read(self.uart.any())
|
||||
if chunk:
|
||||
self.buffer += chunk
|
||||
|
||||
# Process complete lines terminated by a newline character
|
||||
while b'\n' in self.buffer:
|
||||
line, self.buffer = self.buffer.split(b'\n', 1)
|
||||
try:
|
||||
decoded_line = line.decode('utf-8').strip()
|
||||
if decoded_line:
|
||||
self.rx_queue.append(decoded_line)
|
||||
except Exception:
|
||||
pass # Discard corrupt data frames safely
|
||||
except Exception as e:
|
||||
print("[UART Thread Error]:", e)
|
||||
if self.uart.any():
|
||||
with self.lock:
|
||||
line = self.uart.readline()
|
||||
|
||||
time.sleep_ms(20) # Give other background threads breathing room
|
||||
if line:
|
||||
try:
|
||||
decoded = line.decode('utf-8').strip()
|
||||
if decoded: # Ignore empty lines
|
||||
with self.lock:
|
||||
self.rx_queue.append(decoded)
|
||||
except UnicodeError:
|
||||
pass # Drop corrupted bytes cleanly
|
||||
|
||||
time.sleep_ms(10)
|
||||
|
||||
def send(self, message):
|
||||
"""Safely pushes strings across the serial wire from any thread context."""
|
||||
if not message.endswith('\n'):
|
||||
message += '\n'
|
||||
|
||||
with self.lock:
|
||||
self.uart.write(message.encode('utf-8'))
|
||||
|
||||
def read(self):
|
||||
with self.lock:
|
||||
return self.rx_queue.pop(0) if self.rx_queue else None
|
||||
|
||||
def send_as_command(self, command: 'UARTCommand'):
|
||||
"""Safely sends a structured command over UART."""
|
||||
json_message = command.to_json()
|
||||
self.send(json_message)
|
||||
|
||||
def any(self):
|
||||
"""Checks if any complete messages are waiting to be read."""
|
||||
with self.lock:
|
||||
return len(self.rx_queue) > 0
|
||||
|
||||
def read_as_command(self) -> 'UARTCommand | None':
|
||||
"""Attempts to read the oldest unread string and parse it as a UARTCommand. Returns None if empty or invalid."""
|
||||
raw_message = self.read()
|
||||
if raw_message is not None:
|
||||
cmd = UARTCommand.from_json(raw_message)
|
||||
if cmd is None:
|
||||
print("[UART] Impossible de traiter le message brut :", raw_message)
|
||||
return cmd
|
||||
return None
|
||||
|
||||
def read(self):
|
||||
"""Pulls the oldest unread string from the queue. Returns None if empty."""
|
||||
with self.lock:
|
||||
if self.rx_queue:
|
||||
return self.rx_queue.pop(0)
|
||||
return None
|
||||
|
||||
class UARTCommand:
|
||||
"""A simple wrapper for commands sent over UART, allowing for structured data."""
|
||||
def __init__(self, command_type: str, payload):
|
||||
self.command_type = command_type
|
||||
self.payload = payload
|
||||
|
||||
def to_json(self):
|
||||
"""Serializes the command to a JSON string."""
|
||||
return ujson.dumps({
|
||||
"command_type": self.command_type,
|
||||
"payload": self.payload
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def from_json(json_string: str) -> 'UARTCommand | None':
|
||||
"""Deserializes a JSON string into a UARTCommand object."""
|
||||
try:
|
||||
# Remplacement préventif si des guillemets simples sont reçus
|
||||
clean_str = json_string.replace("'", '"') if "'" in json_string else json_string
|
||||
data = ujson.loads(clean_str)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
|
||||
return UARTCommand(data.get("command_type"), data.get("payload"))
|
||||
except Exception as err:
|
||||
# Affiche l'erreur exacte rencontrée par ujson (ex: syntax error)
|
||||
print(f"[UARTCommand Parsing Error]: {err} -> Contenu: {json_string}")
|
||||
return None
|
||||
|
||||
|
||||
class UARTCommandType:
|
||||
"""Enumeration of known UART command types."""
|
||||
COOKING_PARAMS = "COOKING_PARAMS"
|
||||
COOKING_STATE_UPDATE = "COOKING_STATE_UPDATE"
|
||||
@@ -4,7 +4,7 @@ Edit BROKER_HOST so it points to the broker machine IP address.
|
||||
Do not use localhost from the ESP32.
|
||||
"""
|
||||
|
||||
from shared.mqtt import BrokerClient
|
||||
import shared
|
||||
|
||||
|
||||
BROKER_HOST = "192.168.50.1"
|
||||
@@ -17,7 +17,7 @@ def on_message(message):
|
||||
|
||||
|
||||
def main():
|
||||
client = BrokerClient(
|
||||
client = shared.get_mqtt_client(
|
||||
host=BROKER_HOST,
|
||||
client_id="smartwave-esp32-demo",
|
||||
use_tls=True,
|
||||
@@ -28,7 +28,7 @@ def main():
|
||||
client.set_callback(on_message)
|
||||
client.connect()
|
||||
client.subscribe(TOPIC, qos=2)
|
||||
client.publish(TOPIC, b"hello from MicroPython", qos=2, retain=False)
|
||||
client.publish(TOPIC, b"hello from MicroPython", qos=1, retain=False)
|
||||
|
||||
for _ in range(30):
|
||||
client.poll()
|
||||
|
||||
@@ -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
|
||||
@@ -1,827 +0,0 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: charset-normalizer
|
||||
Version: 3.4.9
|
||||
Summary: The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet.
|
||||
Author-email: "Ahmed R. TAHRI" <tahri.ahmed@proton.me>
|
||||
Maintainer-email: "Ahmed R. TAHRI" <tahri.ahmed@proton.me>
|
||||
License: MIT
|
||||
Project-URL: Changelog, https://github.com/jawah/charset_normalizer/blob/master/CHANGELOG.md
|
||||
Project-URL: Documentation, https://charset-normalizer.readthedocs.io/
|
||||
Project-URL: Code, https://github.com/jawah/charset_normalizer
|
||||
Project-URL: Issue tracker, https://github.com/jawah/charset_normalizer/issues
|
||||
Keywords: encoding,charset,charset-detector,detector,normalization,unicode,chardet,detect
|
||||
Classifier: Development Status :: 5 - Production/Stable
|
||||
Classifier: Intended Audience :: Developers
|
||||
Classifier: Operating System :: OS Independent
|
||||
Classifier: Programming Language :: Python
|
||||
Classifier: Programming Language :: Python :: 3
|
||||
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
|
||||
Classifier: Programming Language :: Python :: 3 :: Only
|
||||
Classifier: Programming Language :: Python :: Implementation :: CPython
|
||||
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
||||
Classifier: Programming Language :: Python :: Free Threading :: 4 - Resilient
|
||||
Classifier: Topic :: Text Processing :: Linguistic
|
||||
Classifier: Topic :: Utilities
|
||||
Classifier: Typing :: Typed
|
||||
Requires-Python: >=3.7
|
||||
Description-Content-Type: text/markdown
|
||||
License-File: LICENSE
|
||||
Provides-Extra: unicode-backport
|
||||
Dynamic: license-file
|
||||
|
||||
<h1 align="center">Charset Detection, for Everyone 👋</h1>
|
||||
|
||||
<p align="center">
|
||||
<sup>The Real First Universal Charset Detector</sup><br>
|
||||
<a href="https://pypi.org/project/charset-normalizer">
|
||||
<img src="https://img.shields.io/pypi/pyversions/charset_normalizer.svg?orange=blue" />
|
||||
</a>
|
||||
<a href="https://pepy.tech/project/charset-normalizer/">
|
||||
<img alt="Download Count Total" src="https://static.pepy.tech/badge/charset-normalizer/month" />
|
||||
</a>
|
||||
<a href="https://bestpractices.coreinfrastructure.org/projects/7297">
|
||||
<img src="https://bestpractices.coreinfrastructure.org/projects/7297/badge">
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<sup><i>Featured Packages</i></sup><br>
|
||||
<a href="https://github.com/jawah/niquests">
|
||||
<img alt="Static Badge" src="https://img.shields.io/badge/Niquests-Most_Advanced_HTTP_Client-cyan">
|
||||
</a>
|
||||
<a href="https://github.com/jawah/wassima">
|
||||
<img alt="Static Badge" src="https://img.shields.io/badge/Wassima-Certifi_Replacement-cyan">
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<sup><i>In other language (unofficial port - by the community)</i></sup><br>
|
||||
<a href="https://github.com/nickspring/charset-normalizer-rs">
|
||||
<img alt="Static Badge" src="https://img.shields.io/badge/Rust-red">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
> A library that helps you read text from an unknown charset encoding.<br /> Motivated by `chardet`,
|
||||
> I'm trying to resolve the issue by taking a new approach.
|
||||
> All IANA character set names for which the Python core library provides codecs are supported.
|
||||
> You can also register your own set of codecs, and yes, it would work as-is.
|
||||
|
||||
This project offers you an alternative to **Universal Charset Encoding Detector**, also known as **Chardet**.
|
||||
|
||||
| Feature | [Chardet](https://github.com/chardet/chardet) | Charset Normalizer | [cChardet](https://github.com/PyYoshi/cChardet) |
|
||||
|--------------------------------------------------|:---------------------------------------------:|:-----------------------------------------------------------------------------------------------:|:-----------------------------------------------:|
|
||||
| `Fast` | ✅ | ✅ | ✅ |
|
||||
| `Universal`[^1] | ❌ | ✅ | ❌ |
|
||||
| `Reliable` **without** distinguishable standards | ✅ | ✅ | ✅ |
|
||||
| `Reliable` **with** distinguishable standards | ✅ | ✅ | ✅ |
|
||||
| `License` | _Disputed_[^2]<br>_restrictive_ | MIT | MPL-1.1<br>_restrictive_ |
|
||||
| `Native Python` | ✅ | ✅ | ❌ |
|
||||
| `Detect spoken language` | ✅ | ✅ | N/A |
|
||||
| `UnicodeDecodeError Safety` | ✅ | ✅ | ❌ |
|
||||
| `Whl Size (min)` | 500 kB | 150 kB | ~200 kB |
|
||||
| `Supported Encoding` | 99 | [99](https://charset-normalizer.readthedocs.io/en/latest/user/support.html#supported-encodings) | 40 |
|
||||
| `Can register custom encoding` | ❌ | ✅ | ❌ |
|
||||
|
||||
<p align="center">
|
||||
<img src="https://i.imgflip.com/373iay.gif" alt="Reading Normalized Text" width="226"/><img src="https://media.tenor.com/images/c0180f70732a18b4965448d33adba3d0/tenor.gif" alt="Cat Reading Text" width="200"/>
|
||||
</p>
|
||||
|
||||
[^1]: They are clearly using specific code for a specific encoding even if covering most of used one.
|
||||
[^2]: Chardet 7.0+ was relicensed from LGPL-2.1 to MIT following an AI-assisted rewrite. This relicensing is disputed on two independent grounds: **(a)** the original author [contests](https://github.com/chardet/chardet/issues/327) that the maintainer had the right to relicense, arguing the rewrite is a derivative work of the LGPL-licensed codebase since it was not a clean room implementation; **(b)** the copyright claim itself is [questionable](https://github.com/chardet/chardet/issues/334) given the code was primarily generated by an LLM, and AI-generated output may not be copyrightable under most jurisdictions. Either issue alone could undermine the MIT license. Beyond licensing, the rewrite raises questions about responsible use of AI in open source: key architectural ideas pioneered by charset-normalizer - notably decode-first validity filtering (our foundational approach since v1) and encoding pairwise similarity with the same algorithm and threshold — surfaced in chardet 7 without acknowledgment. The project also imported test files from charset-normalizer to train and benchmark against it, then claimed superior accuracy on those very files. Charset-normalizer has always been MIT-licensed, encoding-agnostic by design, and built on a verifiable human-authored history.
|
||||
|
||||
## ⚡ Performance
|
||||
|
||||
This package offer better performances against Chardet. Here are some numbers.
|
||||
|
||||
| Package | Accuracy | Mean per file (ms) | File per sec (est) |
|
||||
|---------------------------------------------------|:--------:|:------------------:|:------------------:|
|
||||
| [chardet 7.4](https://github.com/chardet/chardet) | 89 % | 3 ms | 333 file/sec |
|
||||
| charset-normalizer | **97 %** | 1 ms | 1000 file/sec |
|
||||
|
||||
| Package | 99th percentile | 95th percentile | 50th percentile |
|
||||
|---------------------------------------------------|:---------------:|:---------------:|:---------------:|
|
||||
| [chardet 7.4](https://github.com/chardet/chardet) | 28 ms | 16 ms | < 1 ms |
|
||||
| charset-normalizer | 8 ms | 5 ms | 1 ms |
|
||||
|
||||
_updated as of July 2026 using CPython 3.12, Charset-Normalizer 3.4.8, and Chardet 7.4.3_
|
||||
|
||||
~Chardet's performance on larger file (1MB+) are very poor. Expect huge difference on large payload.~ No longer the case since Chardet 7.0+
|
||||
|
||||
> Stats are generated using 400+ files using default parameters. More details on used files, see GHA workflows.
|
||||
> And yes, these results might change at any time. The dataset can be updated to include more files.
|
||||
> The actual delays heavily depends on your CPU capabilities. The factors should remain the same.
|
||||
> Chardet claims on his documentation to have a greater accuracy than us based on the dataset they trained Chardet on(...)
|
||||
> Well, it's normal, the opposite would have been worrying. Whereas charset-normalizer don't train on anything, our solution
|
||||
> is based on a completely different algorithm, still heuristic through, it does not need weights across every encoding tables.
|
||||
|
||||
## ✨ Installation
|
||||
|
||||
Using pip:
|
||||
|
||||
```sh
|
||||
pip install charset-normalizer -U
|
||||
```
|
||||
|
||||
## 🚀 Basic Usage
|
||||
|
||||
### CLI
|
||||
This package comes with a CLI.
|
||||
|
||||
```
|
||||
usage: normalizer [-h] [-v] [-a] [-n] [-m] [-r] [-f] [-t THRESHOLD]
|
||||
file [file ...]
|
||||
|
||||
The Real First Universal Charset Detector. Discover originating encoding used
|
||||
on text file. Normalize text to unicode.
|
||||
|
||||
positional arguments:
|
||||
files File(s) to be analysed
|
||||
|
||||
optional arguments:
|
||||
-h, --help show this help message and exit
|
||||
-v, --verbose Display complementary information about file if any.
|
||||
Stdout will contain logs about the detection process.
|
||||
-a, --with-alternative
|
||||
Output complementary possibilities if any. Top-level
|
||||
JSON WILL be a list.
|
||||
-n, --normalize Permit to normalize input file. If not set, program
|
||||
does not write anything.
|
||||
-m, --minimal Only output the charset detected to STDOUT. Disabling
|
||||
JSON output.
|
||||
-r, --replace Replace file when trying to normalize it instead of
|
||||
creating a new one.
|
||||
-f, --force Replace file without asking if you are sure, use this
|
||||
flag with caution.
|
||||
-t THRESHOLD, --threshold THRESHOLD
|
||||
Define a custom maximum amount of chaos allowed in
|
||||
decoded content. 0. <= chaos <= 1.
|
||||
--version Show version information and exit.
|
||||
```
|
||||
|
||||
```bash
|
||||
normalizer ./data/sample.1.fr.srt
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```bash
|
||||
python -m charset_normalizer ./data/sample.1.fr.srt
|
||||
```
|
||||
|
||||
🎉 Since version 1.4.0 the CLI produce easily usable stdout result in JSON format.
|
||||
|
||||
```json
|
||||
{
|
||||
"path": "/home/default/projects/charset_normalizer/data/sample.1.fr.srt",
|
||||
"encoding": "cp1252",
|
||||
"encoding_aliases": [
|
||||
"1252",
|
||||
"windows_1252"
|
||||
],
|
||||
"alternative_encodings": [
|
||||
"cp1254",
|
||||
"cp1256",
|
||||
"cp1258",
|
||||
"iso8859_14",
|
||||
"iso8859_15",
|
||||
"iso8859_16",
|
||||
"iso8859_3",
|
||||
"iso8859_9",
|
||||
"latin_1",
|
||||
"mbcs"
|
||||
],
|
||||
"language": "French",
|
||||
"alphabets": [
|
||||
"Basic Latin",
|
||||
"Latin-1 Supplement"
|
||||
],
|
||||
"has_sig_or_bom": false,
|
||||
"chaos": 0.149,
|
||||
"coherence": 97.152,
|
||||
"unicode_path": null,
|
||||
"is_preferred": true
|
||||
}
|
||||
```
|
||||
|
||||
### Python
|
||||
*Just print out normalized text*
|
||||
```python
|
||||
from charset_normalizer import from_path
|
||||
|
||||
results = from_path('./my_subtitle.srt')
|
||||
|
||||
print(str(results.best()))
|
||||
```
|
||||
|
||||
*Upgrade your code without effort*
|
||||
```python
|
||||
from charset_normalizer import detect
|
||||
```
|
||||
|
||||
The above code will behave the same as **chardet**. We ensure that we offer the best (reasonable) BC result possible.
|
||||
|
||||
See the docs for advanced usage : [readthedocs.io](https://charset-normalizer.readthedocs.io/en/latest/)
|
||||
|
||||
## 😇 Why
|
||||
|
||||
When I started using Chardet, I noticed that it was not suited to my expectations, and I wanted to propose a
|
||||
reliable alternative using a completely different method. Also! I never back down on a good challenge!
|
||||
|
||||
I **don't care** about the **originating charset** encoding, because **two different tables** can
|
||||
produce **two identical rendered string.**
|
||||
What I want is to get readable text, the best I can.
|
||||
|
||||
In a way, **I'm brute forcing text decoding.** How cool is that ? 😎
|
||||
|
||||
Don't confuse package **ftfy** with charset-normalizer or chardet. ftfy goal is to repair Unicode string whereas charset-normalizer to convert raw file in unknown encoding to unicode.
|
||||
|
||||
## 🍰 How
|
||||
|
||||
- Discard all charset encoding table that could not fit the binary content.
|
||||
- Measure noise, or the mess once opened (by chunks) with a corresponding charset encoding.
|
||||
- Extract matches with the lowest mess detected.
|
||||
- Additionally, we measure coherence / probe for a language.
|
||||
|
||||
**Wait a minute**, what is noise/mess and coherence according to **YOU ?**
|
||||
|
||||
*Noise :* I opened hundred of text files, **written by humans**, with the wrong encoding table. **I observed**, then
|
||||
**I established** some ground rules about **what is obvious** when **it seems like** a mess (aka. defining noise in rendered text).
|
||||
I know that my interpretation of what is noise is probably incomplete, feel free to contribute in order to
|
||||
improve or rewrite it.
|
||||
|
||||
*Coherence :* For each language there is on earth, we have computed ranked letter appearance occurrences (the best we can). So I thought
|
||||
that intel is worth something here. So I use those records against decoded text to check if I can detect intelligent design.
|
||||
|
||||
## ⚡ Known limitations
|
||||
|
||||
- Language detection is unreliable when text contains two or more languages sharing identical letters. (eg. HTML (english tags) + Turkish content (Sharing Latin characters))
|
||||
- Every charset detector heavily depends on sufficient content. In common cases, do not bother run detection on very tiny content.
|
||||
|
||||
## ⚠️ About Python EOLs
|
||||
|
||||
**If you are running:**
|
||||
|
||||
- Python >=2.7,<3.5: Unsupported
|
||||
- Python 3.5: charset-normalizer < 2.1
|
||||
- Python 3.6: charset-normalizer < 3.1
|
||||
|
||||
Upgrade your Python interpreter as soon as possible.
|
||||
|
||||
## 👤 Contributing
|
||||
|
||||
Contributions, issues and feature requests are very much welcome.<br />
|
||||
Feel free to check [issues page](https://github.com/ousret/charset_normalizer/issues) if you want to contribute.
|
||||
|
||||
## 📝 License
|
||||
|
||||
Copyright © [Ahmed TAHRI @Ousret](https://github.com/Ousret).<br />
|
||||
This project is [MIT](https://github.com/Ousret/charset_normalizer/blob/master/LICENSE) licensed.
|
||||
|
||||
Characters frequencies used in this project © 2012 [Denny Vrandečić](http://simia.net/letters/)
|
||||
|
||||
## 💼 For Enterprise
|
||||
|
||||
Professional support for charset-normalizer is available as part of the [Tidelift
|
||||
Subscription][1]. Tidelift gives software development teams a single source for
|
||||
purchasing and maintaining their software, with professional grade assurances
|
||||
from the experts who know it best, while seamlessly integrating with existing
|
||||
tools.
|
||||
|
||||
[1]: https://tidelift.com/subscription/pkg/pypi-charset-normalizer?utm_source=pypi-charset-normalizer&utm_medium=readme
|
||||
|
||||
[](https://www.bestpractices.dev/projects/7297)
|
||||
|
||||
# Changelog
|
||||
All notable changes to charset-normalizer will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [3.4.9](https://github.com/Ousret/charset_normalizer/compare/3.4.8...3.4.9) (2026-07-07)
|
||||
|
||||
### Fixed
|
||||
- Regression in our fallback path leading to a decode error. (#771)
|
||||
We've yanked 3.4.8 as a result of that bug.
|
||||
|
||||
## [3.4.8](https://github.com/Ousret/charset_normalizer/compare/3.4.7...3.4.8) (2026-07-06)
|
||||
|
||||
### Fixed
|
||||
- Wall import time due to cascade codec imports for our multibyte first sort of iana supported codecs (#742)
|
||||
- Unnecessary json import at runtime (#753)
|
||||
- Inverse capitalization not seen by noise detector (#731)
|
||||
|
||||
### Changed
|
||||
- No longer holding a global cache for our noise / coherence measurements. Relax RSS memory usage.
|
||||
- Micro-optimizations in our noise / coherence measurements.
|
||||
- No longer using regex search by default for our preemptive charset mark algorithm.
|
||||
- Raised upperbound of setuptools to v83.
|
||||
- Raised upperbound of mypy(c) to v2.1.
|
||||
|
||||
### Removed
|
||||
- Redundant UTF7 BOM marker (#730)
|
||||
|
||||
## [3.4.7](https://github.com/Ousret/charset_normalizer/compare/3.4.6...3.4.7) (2026-04-02)
|
||||
|
||||
### Changed
|
||||
- Pre-built optimized version using mypy[c] v1.20.
|
||||
- Relax `setuptools` constraint to `setuptools>=68,<82.1`.
|
||||
|
||||
### Fixed
|
||||
- Correctly remove SIG remnant in utf-7 decoded string. (#718) (#716)
|
||||
|
||||
## [3.4.6](https://github.com/Ousret/charset_normalizer/compare/3.4.5...3.4.6) (2026-03-15)
|
||||
|
||||
### Changed
|
||||
- Flattened the logic in `charset_normalizer.md` for higher performance. Removed `eligible(..)` and `feed(...)`
|
||||
in favor of `feed_info(...)`.
|
||||
- Raised upper bound for mypy[c] to 1.20, for our optimized version.
|
||||
- Updated `UNICODE_RANGES_COMBINED` using Unicode blocks v17.
|
||||
|
||||
### Fixed
|
||||
- Edge case where noise difference between two candidates can be almost insignificant. (#672)
|
||||
- CLI `--normalize` writing to wrong path when passing multiple files in. (#702)
|
||||
|
||||
### Misc
|
||||
- Freethreaded pre-built wheels now shipped in PyPI starting with 3.14t. (#616)
|
||||
|
||||
## [3.4.5](https://github.com/Ousret/charset_normalizer/compare/3.4.4...3.4.5) (2026-03-06)
|
||||
|
||||
### Changed
|
||||
- Update `setuptools` constraint to `setuptools>=68,<=82`.
|
||||
- Raised upper bound of mypyc for the optional pre-built extension to v1.19.1
|
||||
|
||||
### Fixed
|
||||
- Add explicit link to lib math in our optimized build. (#692)
|
||||
- Logger level not restored correctly for empty byte sequences. (#701)
|
||||
- TypeError when passing bytearray to from_bytes. (#703)
|
||||
|
||||
### Misc
|
||||
- Applied safe micro-optimizations in both our noise detector and language detector.
|
||||
- Rewrote the `query_yes_no` function (inside CLI) to avoid using ambiguous licensed code.
|
||||
- Added `cd.py` submodule into mypyc optional compilation to reduce further the performance impact.
|
||||
|
||||
## [3.4.4](https://github.com/Ousret/charset_normalizer/compare/3.4.2...3.4.4) (2025-10-13)
|
||||
|
||||
### Changed
|
||||
- Bound `setuptools` to a specific constraint `setuptools>=68,<=81`.
|
||||
- Raised upper bound of mypyc for the optional pre-built extension to v1.18.2
|
||||
|
||||
### Removed
|
||||
- `setuptools-scm` as a build dependency.
|
||||
|
||||
### Misc
|
||||
- Enforced hashes in `dev-requirements.txt` and created `ci-requirements.txt` for security purposes.
|
||||
- Additional pre-built wheels for riscv64, s390x, and armv7l architectures.
|
||||
- Restore ` multiple.intoto.jsonl` in GitHub releases in addition to individual attestation file per wheel.
|
||||
|
||||
## [3.4.3](https://github.com/Ousret/charset_normalizer/compare/3.4.2...3.4.3) (2025-08-09)
|
||||
|
||||
### Changed
|
||||
- mypy(c) is no longer a required dependency at build time if `CHARSET_NORMALIZER_USE_MYPYC` isn't set to `1`. (#595) (#583)
|
||||
- automatically lower confidence on small bytes samples that are not Unicode in `detect` output legacy function. (#391)
|
||||
|
||||
### Added
|
||||
- Custom build backend to overcome inability to mark mypy as an optional dependency in the build phase.
|
||||
- Support for Python 3.14
|
||||
|
||||
### Fixed
|
||||
- sdist archive contained useless directories.
|
||||
- automatically fallback on valid UTF-16 or UTF-32 even if the md says it's noisy. (#633)
|
||||
|
||||
### Misc
|
||||
- SBOM are automatically published to the relevant GitHub release to comply with regulatory changes.
|
||||
Each published wheel comes with its SBOM. We choose CycloneDX as the format.
|
||||
- Prebuilt optimized wheel are no longer distributed by default for CPython 3.7 due to a change in cibuildwheel.
|
||||
|
||||
## [3.4.2](https://github.com/Ousret/charset_normalizer/compare/3.4.1...3.4.2) (2025-05-02)
|
||||
|
||||
### Fixed
|
||||
- Addressed the DeprecationWarning in our CLI regarding `argparse.FileType` by backporting the target class into the package. (#591)
|
||||
- Improved the overall reliability of the detector with CJK Ideographs. (#605) (#587)
|
||||
|
||||
### Changed
|
||||
- Optional mypyc compilation upgraded to version 1.15 for Python >= 3.8
|
||||
|
||||
## [3.4.1](https://github.com/Ousret/charset_normalizer/compare/3.4.0...3.4.1) (2024-12-24)
|
||||
|
||||
### Changed
|
||||
- Project metadata are now stored using `pyproject.toml` instead of `setup.cfg` using setuptools as the build backend.
|
||||
- Enforce annotation delayed loading for a simpler and consistent types in the project.
|
||||
- Optional mypyc compilation upgraded to version 1.14 for Python >= 3.8
|
||||
|
||||
### Added
|
||||
- pre-commit configuration.
|
||||
- noxfile.
|
||||
|
||||
### Removed
|
||||
- `build-requirements.txt` as per using `pyproject.toml` native build configuration.
|
||||
- `bin/integration.py` and `bin/serve.py` in favor of downstream integration test (see noxfile).
|
||||
- `setup.cfg` in favor of `pyproject.toml` metadata configuration.
|
||||
- Unused `utils.range_scan` function.
|
||||
|
||||
### Fixed
|
||||
- Converting content to Unicode bytes may insert `utf_8` instead of preferred `utf-8`. (#572)
|
||||
- Deprecation warning "'count' is passed as positional argument" when converting to Unicode bytes on Python 3.13+
|
||||
|
||||
## [3.4.0](https://github.com/Ousret/charset_normalizer/compare/3.3.2...3.4.0) (2024-10-08)
|
||||
|
||||
### Added
|
||||
- Argument `--no-preemptive` in the CLI to prevent the detector to search for hints.
|
||||
- Support for Python 3.13 (#512)
|
||||
|
||||
### Fixed
|
||||
- Relax the TypeError exception thrown when trying to compare a CharsetMatch with anything else than a CharsetMatch.
|
||||
- Improved the general reliability of the detector based on user feedbacks. (#520) (#509) (#498) (#407) (#537)
|
||||
- Declared charset in content (preemptive detection) not changed when converting to utf-8 bytes. (#381)
|
||||
|
||||
## [3.3.2](https://github.com/Ousret/charset_normalizer/compare/3.3.1...3.3.2) (2023-10-31)
|
||||
|
||||
### Fixed
|
||||
- Unintentional memory usage regression when using large payload that match several encoding (#376)
|
||||
- Regression on some detection case showcased in the documentation (#371)
|
||||
|
||||
### Added
|
||||
- Noise (md) probe that identify malformed arabic representation due to the presence of letters in isolated form (credit to my wife)
|
||||
|
||||
## [3.3.1](https://github.com/Ousret/charset_normalizer/compare/3.3.0...3.3.1) (2023-10-22)
|
||||
|
||||
### Changed
|
||||
- Optional mypyc compilation upgraded to version 1.6.1 for Python >= 3.8
|
||||
- Improved the general detection reliability based on reports from the community
|
||||
|
||||
## [3.3.0](https://github.com/Ousret/charset_normalizer/compare/3.2.0...3.3.0) (2023-09-30)
|
||||
|
||||
### Added
|
||||
- Allow to execute the CLI (e.g. normalizer) through `python -m charset_normalizer.cli` or `python -m charset_normalizer`
|
||||
- Support for 9 forgotten encoding that are supported by Python but unlisted in `encoding.aliases` as they have no alias (#323)
|
||||
|
||||
### Removed
|
||||
- (internal) Redundant utils.is_ascii function and unused function is_private_use_only
|
||||
- (internal) charset_normalizer.assets is moved inside charset_normalizer.constant
|
||||
|
||||
### Changed
|
||||
- (internal) Unicode code blocks in constants are updated using the latest v15.0.0 definition to improve detection
|
||||
- Optional mypyc compilation upgraded to version 1.5.1 for Python >= 3.8
|
||||
|
||||
### Fixed
|
||||
- Unable to properly sort CharsetMatch when both chaos/noise and coherence were close due to an unreachable condition in \_\_lt\_\_ (#350)
|
||||
|
||||
## [3.2.0](https://github.com/Ousret/charset_normalizer/compare/3.1.0...3.2.0) (2023-06-07)
|
||||
|
||||
### Changed
|
||||
- Typehint for function `from_path` no longer enforce `PathLike` as its first argument
|
||||
- Minor improvement over the global detection reliability
|
||||
|
||||
### Added
|
||||
- Introduce function `is_binary` that relies on main capabilities, and optimized to detect binaries
|
||||
- Propagate `enable_fallback` argument throughout `from_bytes`, `from_path`, and `from_fp` that allow a deeper control over the detection (default True)
|
||||
- Explicit support for Python 3.12
|
||||
|
||||
### Fixed
|
||||
- Edge case detection failure where a file would contain 'very-long' camel cased word (Issue #289)
|
||||
|
||||
## [3.1.0](https://github.com/Ousret/charset_normalizer/compare/3.0.1...3.1.0) (2023-03-06)
|
||||
|
||||
### Added
|
||||
- Argument `should_rename_legacy` for legacy function `detect` and disregard any new arguments without errors (PR #262)
|
||||
|
||||
### Removed
|
||||
- Support for Python 3.6 (PR #260)
|
||||
|
||||
### Changed
|
||||
- Optional speedup provided by mypy/c 1.0.1
|
||||
|
||||
## [3.0.1](https://github.com/Ousret/charset_normalizer/compare/3.0.0...3.0.1) (2022-11-18)
|
||||
|
||||
### Fixed
|
||||
- Multi-bytes cutter/chunk generator did not always cut correctly (PR #233)
|
||||
|
||||
### Changed
|
||||
- Speedup provided by mypy/c 0.990 on Python >= 3.7
|
||||
|
||||
## [3.0.0](https://github.com/Ousret/charset_normalizer/compare/2.1.1...3.0.0) (2022-10-20)
|
||||
|
||||
### Added
|
||||
- Extend the capability of explain=True when cp_isolation contains at most two entries (min one), will log in details of the Mess-detector results
|
||||
- Support for alternative language frequency set in charset_normalizer.assets.FREQUENCIES
|
||||
- Add parameter `language_threshold` in `from_bytes`, `from_path` and `from_fp` to adjust the minimum expected coherence ratio
|
||||
- `normalizer --version` now specify if current version provide extra speedup (meaning mypyc compilation whl)
|
||||
|
||||
### Changed
|
||||
- Build with static metadata using 'build' frontend
|
||||
- Make the language detection stricter
|
||||
- Optional: Module `md.py` can be compiled using Mypyc to provide an extra speedup up to 4x faster than v2.1
|
||||
|
||||
### Fixed
|
||||
- CLI with opt --normalize fail when using full path for files
|
||||
- TooManyAccentuatedPlugin induce false positive on the mess detection when too few alpha character have been fed to it
|
||||
- Sphinx warnings when generating the documentation
|
||||
|
||||
### Removed
|
||||
- Coherence detector no longer return 'Simple English' instead return 'English'
|
||||
- Coherence detector no longer return 'Classical Chinese' instead return 'Chinese'
|
||||
- Breaking: Method `first()` and `best()` from CharsetMatch
|
||||
- UTF-7 will no longer appear as "detected" without a recognized SIG/mark (is unreliable/conflict with ASCII)
|
||||
- Breaking: Class aliases CharsetDetector, CharsetDoctor, CharsetNormalizerMatch and CharsetNormalizerMatches
|
||||
- Breaking: Top-level function `normalize`
|
||||
- Breaking: Properties `chaos_secondary_pass`, `coherence_non_latin` and `w_counter` from CharsetMatch
|
||||
- Support for the backport `unicodedata2`
|
||||
|
||||
## [3.0.0rc1](https://github.com/Ousret/charset_normalizer/compare/3.0.0b2...3.0.0rc1) (2022-10-18)
|
||||
|
||||
### Added
|
||||
- Extend the capability of explain=True when cp_isolation contains at most two entries (min one), will log in details of the Mess-detector results
|
||||
- Support for alternative language frequency set in charset_normalizer.assets.FREQUENCIES
|
||||
- Add parameter `language_threshold` in `from_bytes`, `from_path` and `from_fp` to adjust the minimum expected coherence ratio
|
||||
|
||||
### Changed
|
||||
- Build with static metadata using 'build' frontend
|
||||
- Make the language detection stricter
|
||||
|
||||
### Fixed
|
||||
- CLI with opt --normalize fail when using full path for files
|
||||
- TooManyAccentuatedPlugin induce false positive on the mess detection when too few alpha character have been fed to it
|
||||
|
||||
### Removed
|
||||
- Coherence detector no longer return 'Simple English' instead return 'English'
|
||||
- Coherence detector no longer return 'Classical Chinese' instead return 'Chinese'
|
||||
|
||||
## [3.0.0b2](https://github.com/Ousret/charset_normalizer/compare/3.0.0b1...3.0.0b2) (2022-08-21)
|
||||
|
||||
### Added
|
||||
- `normalizer --version` now specify if current version provide extra speedup (meaning mypyc compilation whl)
|
||||
|
||||
### Removed
|
||||
- Breaking: Method `first()` and `best()` from CharsetMatch
|
||||
- UTF-7 will no longer appear as "detected" without a recognized SIG/mark (is unreliable/conflict with ASCII)
|
||||
|
||||
### Fixed
|
||||
- Sphinx warnings when generating the documentation
|
||||
|
||||
## [3.0.0b1](https://github.com/Ousret/charset_normalizer/compare/2.1.0...3.0.0b1) (2022-08-15)
|
||||
|
||||
### Changed
|
||||
- Optional: Module `md.py` can be compiled using Mypyc to provide an extra speedup up to 4x faster than v2.1
|
||||
|
||||
### Removed
|
||||
- Breaking: Class aliases CharsetDetector, CharsetDoctor, CharsetNormalizerMatch and CharsetNormalizerMatches
|
||||
- Breaking: Top-level function `normalize`
|
||||
- Breaking: Properties `chaos_secondary_pass`, `coherence_non_latin` and `w_counter` from CharsetMatch
|
||||
- Support for the backport `unicodedata2`
|
||||
|
||||
## [2.1.1](https://github.com/Ousret/charset_normalizer/compare/2.1.0...2.1.1) (2022-08-19)
|
||||
|
||||
### Deprecated
|
||||
- Function `normalize` scheduled for removal in 3.0
|
||||
|
||||
### Changed
|
||||
- Removed useless call to decode in fn is_unprintable (#206)
|
||||
|
||||
### Fixed
|
||||
- Third-party library (i18n xgettext) crashing not recognizing utf_8 (PEP 263) with underscore from [@aleksandernovikov](https://github.com/aleksandernovikov) (#204)
|
||||
|
||||
## [2.1.0](https://github.com/Ousret/charset_normalizer/compare/2.0.12...2.1.0) (2022-06-19)
|
||||
|
||||
### Added
|
||||
- Output the Unicode table version when running the CLI with `--version` (PR #194)
|
||||
|
||||
### Changed
|
||||
- Reuse decoded buffer for single byte character sets from [@nijel](https://github.com/nijel) (PR #175)
|
||||
- Fixing some performance bottlenecks from [@deedy5](https://github.com/deedy5) (PR #183)
|
||||
|
||||
### Fixed
|
||||
- Workaround potential bug in cpython with Zero Width No-Break Space located in Arabic Presentation Forms-B, Unicode 1.1 not acknowledged as space (PR #175)
|
||||
- CLI default threshold aligned with the API threshold from [@oleksandr-kuzmenko](https://github.com/oleksandr-kuzmenko) (PR #181)
|
||||
|
||||
### Removed
|
||||
- Support for Python 3.5 (PR #192)
|
||||
|
||||
### Deprecated
|
||||
- Use of backport unicodedata from `unicodedata2` as Python is quickly catching up, scheduled for removal in 3.0 (PR #194)
|
||||
|
||||
## [2.0.12](https://github.com/Ousret/charset_normalizer/compare/2.0.11...2.0.12) (2022-02-12)
|
||||
|
||||
### Fixed
|
||||
- ASCII miss-detection on rare cases (PR #170)
|
||||
|
||||
## [2.0.11](https://github.com/Ousret/charset_normalizer/compare/2.0.10...2.0.11) (2022-01-30)
|
||||
|
||||
### Added
|
||||
- Explicit support for Python 3.11 (PR #164)
|
||||
|
||||
### Changed
|
||||
- The logging behavior have been completely reviewed, now using only TRACE and DEBUG levels (PR #163 #165)
|
||||
|
||||
## [2.0.10](https://github.com/Ousret/charset_normalizer/compare/2.0.9...2.0.10) (2022-01-04)
|
||||
|
||||
### Fixed
|
||||
- Fallback match entries might lead to UnicodeDecodeError for large bytes sequence (PR #154)
|
||||
|
||||
### Changed
|
||||
- Skipping the language-detection (CD) on ASCII (PR #155)
|
||||
|
||||
## [2.0.9](https://github.com/Ousret/charset_normalizer/compare/2.0.8...2.0.9) (2021-12-03)
|
||||
|
||||
### Changed
|
||||
- Moderating the logging impact (since 2.0.8) for specific environments (PR #147)
|
||||
|
||||
### Fixed
|
||||
- Wrong logging level applied when setting kwarg `explain` to True (PR #146)
|
||||
|
||||
## [2.0.8](https://github.com/Ousret/charset_normalizer/compare/2.0.7...2.0.8) (2021-11-24)
|
||||
### Changed
|
||||
- Improvement over Vietnamese detection (PR #126)
|
||||
- MD improvement on trailing data and long foreign (non-pure latin) data (PR #124)
|
||||
- Efficiency improvements in cd/alphabet_languages from [@adbar](https://github.com/adbar) (PR #122)
|
||||
- call sum() without an intermediary list following PEP 289 recommendations from [@adbar](https://github.com/adbar) (PR #129)
|
||||
- Code style as refactored by Sourcery-AI (PR #131)
|
||||
- Minor adjustment on the MD around european words (PR #133)
|
||||
- Remove and replace SRTs from assets / tests (PR #139)
|
||||
- Initialize the library logger with a `NullHandler` by default from [@nmaynes](https://github.com/nmaynes) (PR #135)
|
||||
- Setting kwarg `explain` to True will add provisionally (bounded to function lifespan) a specific stream handler (PR #135)
|
||||
|
||||
### Fixed
|
||||
- Fix large (misleading) sequence giving UnicodeDecodeError (PR #137)
|
||||
- Avoid using too insignificant chunk (PR #137)
|
||||
|
||||
### Added
|
||||
- Add and expose function `set_logging_handler` to configure a specific StreamHandler from [@nmaynes](https://github.com/nmaynes) (PR #135)
|
||||
- Add `CHANGELOG.md` entries, format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) (PR #141)
|
||||
|
||||
## [2.0.7](https://github.com/Ousret/charset_normalizer/compare/2.0.6...2.0.7) (2021-10-11)
|
||||
### Added
|
||||
- Add support for Kazakh (Cyrillic) language detection (PR #109)
|
||||
|
||||
### Changed
|
||||
- Further, improve inferring the language from a given single-byte code page (PR #112)
|
||||
- Vainly trying to leverage PEP263 when PEP3120 is not supported (PR #116)
|
||||
- Refactoring for potential performance improvements in loops from [@adbar](https://github.com/adbar) (PR #113)
|
||||
- Various detection improvement (MD+CD) (PR #117)
|
||||
|
||||
### Removed
|
||||
- Remove redundant logging entry about detected language(s) (PR #115)
|
||||
|
||||
### Fixed
|
||||
- Fix a minor inconsistency between Python 3.5 and other versions regarding language detection (PR #117 #102)
|
||||
|
||||
## [2.0.6](https://github.com/Ousret/charset_normalizer/compare/2.0.5...2.0.6) (2021-09-18)
|
||||
### Fixed
|
||||
- Unforeseen regression with the loss of the backward-compatibility with some older minor of Python 3.5.x (PR #100)
|
||||
- Fix CLI crash when using --minimal output in certain cases (PR #103)
|
||||
|
||||
### Changed
|
||||
- Minor improvement to the detection efficiency (less than 1%) (PR #106 #101)
|
||||
|
||||
## [2.0.5](https://github.com/Ousret/charset_normalizer/compare/2.0.4...2.0.5) (2021-09-14)
|
||||
### Changed
|
||||
- The project now comply with: flake8, mypy, isort and black to ensure a better overall quality (PR #81)
|
||||
- The BC-support with v1.x was improved, the old staticmethods are restored (PR #82)
|
||||
- The Unicode detection is slightly improved (PR #93)
|
||||
- Add syntax sugar \_\_bool\_\_ for results CharsetMatches list-container (PR #91)
|
||||
|
||||
### Removed
|
||||
- The project no longer raise warning on tiny content given for detection, will be simply logged as warning instead (PR #92)
|
||||
|
||||
### Fixed
|
||||
- In some rare case, the chunks extractor could cut in the middle of a multi-byte character and could mislead the mess detection (PR #95)
|
||||
- Some rare 'space' characters could trip up the UnprintablePlugin/Mess detection (PR #96)
|
||||
- The MANIFEST.in was not exhaustive (PR #78)
|
||||
|
||||
## [2.0.4](https://github.com/Ousret/charset_normalizer/compare/2.0.3...2.0.4) (2021-07-30)
|
||||
### Fixed
|
||||
- The CLI no longer raise an unexpected exception when no encoding has been found (PR #70)
|
||||
- Fix accessing the 'alphabets' property when the payload contains surrogate characters (PR #68)
|
||||
- The logger could mislead (explain=True) on detected languages and the impact of one MBCS match (PR #72)
|
||||
- Submatch factoring could be wrong in rare edge cases (PR #72)
|
||||
- Multiple files given to the CLI were ignored when publishing results to STDOUT. (After the first path) (PR #72)
|
||||
- Fix line endings from CRLF to LF for certain project files (PR #67)
|
||||
|
||||
### Changed
|
||||
- Adjust the MD to lower the sensitivity, thus improving the global detection reliability (PR #69 #76)
|
||||
- Allow fallback on specified encoding if any (PR #71)
|
||||
|
||||
## [2.0.3](https://github.com/Ousret/charset_normalizer/compare/2.0.2...2.0.3) (2021-07-16)
|
||||
### Changed
|
||||
- Part of the detection mechanism has been improved to be less sensitive, resulting in more accurate detection results. Especially ASCII. (PR #63)
|
||||
- According to the community wishes, the detection will fall back on ASCII or UTF-8 in a last-resort case. (PR #64)
|
||||
|
||||
## [2.0.2](https://github.com/Ousret/charset_normalizer/compare/2.0.1...2.0.2) (2021-07-15)
|
||||
### Fixed
|
||||
- Empty/Too small JSON payload miss-detection fixed. Report from [@tseaver](https://github.com/tseaver) (PR #59)
|
||||
|
||||
### Changed
|
||||
- Don't inject unicodedata2 into sys.modules from [@akx](https://github.com/akx) (PR #57)
|
||||
|
||||
## [2.0.1](https://github.com/Ousret/charset_normalizer/compare/2.0.0...2.0.1) (2021-07-13)
|
||||
### Fixed
|
||||
- Make it work where there isn't a filesystem available, dropping assets frequencies.json. Report from [@sethmlarson](https://github.com/sethmlarson). (PR #55)
|
||||
- Using explain=False permanently disable the verbose output in the current runtime (PR #47)
|
||||
- One log entry (language target preemptive) was not show in logs when using explain=True (PR #47)
|
||||
- Fix undesired exception (ValueError) on getitem of instance CharsetMatches (PR #52)
|
||||
|
||||
### Changed
|
||||
- Public function normalize default args values were not aligned with from_bytes (PR #53)
|
||||
|
||||
### Added
|
||||
- You may now use charset aliases in cp_isolation and cp_exclusion arguments (PR #47)
|
||||
|
||||
## [2.0.0](https://github.com/Ousret/charset_normalizer/compare/1.4.1...2.0.0) (2021-07-02)
|
||||
### Changed
|
||||
- 4x to 5 times faster than the previous 1.4.0 release. At least 2x faster than Chardet.
|
||||
- Accent has been made on UTF-8 detection, should perform rather instantaneous.
|
||||
- The backward compatibility with Chardet has been greatly improved. The legacy detect function returns an identical charset name whenever possible.
|
||||
- The detection mechanism has been slightly improved, now Turkish content is detected correctly (most of the time)
|
||||
- The program has been rewritten to ease the readability and maintainability. (+Using static typing)+
|
||||
- utf_7 detection has been reinstated.
|
||||
|
||||
### Removed
|
||||
- This package no longer require anything when used with Python 3.5 (Dropped cached_property)
|
||||
- Removed support for these languages: Catalan, Esperanto, Kazakh, Baque, Volapük, Azeri, Galician, Nynorsk, Macedonian, and Serbocroatian.
|
||||
- The exception hook on UnicodeDecodeError has been removed.
|
||||
|
||||
### Deprecated
|
||||
- Methods coherence_non_latin, w_counter, chaos_secondary_pass of the class CharsetMatch are now deprecated and scheduled for removal in v3.0
|
||||
|
||||
### Fixed
|
||||
- The CLI output used the relative path of the file(s). Should be absolute.
|
||||
|
||||
## [1.4.1](https://github.com/Ousret/charset_normalizer/compare/1.4.0...1.4.1) (2021-05-28)
|
||||
### Fixed
|
||||
- Logger configuration/usage no longer conflict with others (PR #44)
|
||||
|
||||
## [1.4.0](https://github.com/Ousret/charset_normalizer/compare/1.3.9...1.4.0) (2021-05-21)
|
||||
### Removed
|
||||
- Using standard logging instead of using the package loguru.
|
||||
- Dropping nose test framework in favor of the maintained pytest.
|
||||
- Choose to not use dragonmapper package to help with gibberish Chinese/CJK text.
|
||||
- Require cached_property only for Python 3.5 due to constraint. Dropping for every other interpreter version.
|
||||
- Stop support for UTF-7 that does not contain a SIG.
|
||||
- Dropping PrettyTable, replaced with pure JSON output in CLI.
|
||||
|
||||
### Fixed
|
||||
- BOM marker in a CharsetNormalizerMatch instance could be False in rare cases even if obviously present. Due to the sub-match factoring process.
|
||||
- Not searching properly for the BOM when trying utf32/16 parent codec.
|
||||
|
||||
### Changed
|
||||
- Improving the package final size by compressing frequencies.json.
|
||||
- Huge improvement over the larges payload.
|
||||
|
||||
### Added
|
||||
- CLI now produces JSON consumable output.
|
||||
- Return ASCII if given sequences fit. Given reasonable confidence.
|
||||
|
||||
## [1.3.9](https://github.com/Ousret/charset_normalizer/compare/1.3.8...1.3.9) (2021-05-13)
|
||||
|
||||
### Fixed
|
||||
- In some very rare cases, you may end up getting encode/decode errors due to a bad bytes payload (PR #40)
|
||||
|
||||
## [1.3.8](https://github.com/Ousret/charset_normalizer/compare/1.3.7...1.3.8) (2021-05-12)
|
||||
|
||||
### Fixed
|
||||
- Empty given payload for detection may cause an exception if trying to access the `alphabets` property. (PR #39)
|
||||
|
||||
## [1.3.7](https://github.com/Ousret/charset_normalizer/compare/1.3.6...1.3.7) (2021-05-12)
|
||||
|
||||
### Fixed
|
||||
- The legacy detect function should return UTF-8-SIG if sig is present in the payload. (PR #38)
|
||||
|
||||
## [1.3.6](https://github.com/Ousret/charset_normalizer/compare/1.3.5...1.3.6) (2021-02-09)
|
||||
|
||||
### Changed
|
||||
- Amend the previous release to allow prettytable 2.0 (PR #35)
|
||||
|
||||
## [1.3.5](https://github.com/Ousret/charset_normalizer/compare/1.3.4...1.3.5) (2021-02-08)
|
||||
|
||||
### Fixed
|
||||
- Fix error while using the package with a python pre-release interpreter (PR #33)
|
||||
|
||||
### Changed
|
||||
- Dependencies refactoring, constraints revised.
|
||||
|
||||
### Added
|
||||
- Add python 3.9 and 3.10 to the supported interpreters
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 TAHRI Ahmed R.
|
||||
|
||||
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,36 +0,0 @@
|
||||
../../../bin/normalizer,sha256=g0IlkIjn1xmaIFNjQj1k40XlzyG1Hj51rQ1SX3MF8BY,277
|
||||
ada92cb5d92a588d1b93__mypyc.cpython-312-x86_64-linux-gnu.so,sha256=aIdLxZY-RSJ2C4KiLW5ye2ucy6X1_pw77SdPSlhVZyI,457584
|
||||
charset_normalizer-3.4.9.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||
charset_normalizer-3.4.9.dist-info/METADATA,sha256=DdPeF2CbDbZL0dRkPFvQ3TW16zxb53-LF7wZDy9KKsA,41678
|
||||
charset_normalizer-3.4.9.dist-info/RECORD,,
|
||||
charset_normalizer-3.4.9.dist-info/WHEEL,sha256=kPfBHUCXEMd9_xazqE-bWrrpnGTuP83yeVdC3i0eh6U,190
|
||||
charset_normalizer-3.4.9.dist-info/entry_points.txt,sha256=ADSTKrkXZ3hhdOVFi6DcUEHQRS0xfxDIE_pEz4wLIXA,65
|
||||
charset_normalizer-3.4.9.dist-info/licenses/LICENSE,sha256=bQ1Bv-FwrGx9wkjJpj4lTQ-0WmDVCoJX0K-SxuJJuIc,1071
|
||||
charset_normalizer-3.4.9.dist-info/top_level.txt,sha256=y65Zf_GLs5FHscGHzcUTGAldXcp5mEVXbh8Mo0ZlmWs,47
|
||||
charset_normalizer/__init__.py,sha256=OKRxRv2Zhnqk00tqkN0c1BtJjm165fWXLydE52IKuHc,1590
|
||||
charset_normalizer/__main__.py,sha256=yzYxMR-IhKRHYwcSlavEv8oGdwxsR89mr2X09qXGdps,109
|
||||
charset_normalizer/__pycache__/__init__.cpython-312.pyc,,
|
||||
charset_normalizer/__pycache__/__main__.cpython-312.pyc,,
|
||||
charset_normalizer/__pycache__/api.cpython-312.pyc,,
|
||||
charset_normalizer/__pycache__/cd.cpython-312.pyc,,
|
||||
charset_normalizer/__pycache__/constant.cpython-312.pyc,,
|
||||
charset_normalizer/__pycache__/legacy.cpython-312.pyc,,
|
||||
charset_normalizer/__pycache__/md.cpython-312.pyc,,
|
||||
charset_normalizer/__pycache__/models.cpython-312.pyc,,
|
||||
charset_normalizer/__pycache__/utils.cpython-312.pyc,,
|
||||
charset_normalizer/__pycache__/version.cpython-312.pyc,,
|
||||
charset_normalizer/api.py,sha256=kXhFlZNMi6_p0YhbTeGTswoK_DZ6oeAdprPxE8F4yfM,42325
|
||||
charset_normalizer/cd.cpython-312-x86_64-linux-gnu.so,sha256=R9WQd3NirBV0quIUAT8Kr7kQ_h2EqUb94KGDOxFxxV4,16040
|
||||
charset_normalizer/cd.py,sha256=6dJOZvd0rrEEWvkx54kAEy9qAP_uIuiWMAi5wX8OZsY,15980
|
||||
charset_normalizer/cli/__init__.py,sha256=D8I86lFk2-py45JvqxniTirSj_sFyE6sjaY_0-G1shc,136
|
||||
charset_normalizer/cli/__main__.py,sha256=NPK3yYAkXXE7Uvs6IqijkgZzS6G6fguRFuKXzgpf8Uc,11949
|
||||
charset_normalizer/cli/__pycache__/__init__.cpython-312.pyc,,
|
||||
charset_normalizer/cli/__pycache__/__main__.cpython-312.pyc,,
|
||||
charset_normalizer/constant.py,sha256=vp9HvaompccS71BXPZk8lVahonkPbBpeVE3NzKZe2sk,44624
|
||||
charset_normalizer/legacy.py,sha256=bgkdEvubTCOPlMKOzuENXHMiarh36VaGbRvWSlIr1KA,2651
|
||||
charset_normalizer/md.cpython-312-x86_64-linux-gnu.so,sha256=1NCejiMAxx7khs9xwL4V2f-dahqGXknNz304xK9TJbY,16040
|
||||
charset_normalizer/md.py,sha256=bwD18o2xLkGxBkuM1HPnYULsLBzNVe09j727Tng2tJw,32641
|
||||
charset_normalizer/models.py,sha256=BAYguAENiSKBb7mf3DKzY3qgqjrjBeP0dySCmQSieXE,12830
|
||||
charset_normalizer/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
charset_normalizer/utils.py,sha256=fMCjgJXzMFegyO-gX-NElZi1rmdF1y1xax2Y-V2eudo,13537
|
||||
charset_normalizer/version.py,sha256=FUQoaRCvGW_xwCoesgsGTwzekLTlCkAJr7diNpv-gPI,115
|
||||
@@ -1,7 +0,0 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: setuptools (83.0.0)
|
||||
Root-Is-Purelib: false
|
||||
Tag: cp312-cp312-manylinux_2_17_x86_64
|
||||
Tag: cp312-cp312-manylinux2014_x86_64
|
||||
Tag: cp312-cp312-manylinux_2_28_x86_64
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user