Compare commits
27 Commits
2dd664c4b4
...
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 |
+2
-1
@@ -1,3 +1,4 @@
|
||||
__pycache__/
|
||||
orchestrateur/db.sqlite-shm
|
||||
venv/
|
||||
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`
|
||||
+52
-19
@@ -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__)
|
||||
|
||||
@@ -24,13 +29,20 @@ device_network_collection = db["device_network"]
|
||||
CAMERA_IMAGE_DIR = "storage/dishCameraImages"
|
||||
os.makedirs(CAMERA_IMAGE_DIR, exist_ok=True)
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Classes
|
||||
# ---------------------------------------------------------
|
||||
microwave_cook_planner = MicrowaveCookPlanner()
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@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,40 +52,56 @@ def cooking_params():
|
||||
if not data:
|
||||
return jsonify({"error": "Invalid or missing JSON payload"}), 400
|
||||
|
||||
# Extract user or device parameters (with fallback defaults)
|
||||
height_cm = float(data.get("dish_height", 4.0))
|
||||
initial_temp_c = float(data.get("ir_initial_temp", 20.0)) # e.g., 4.0 for fridge, -18.0 for freezer
|
||||
microwave_wattage = int(data.get("microwave_wattage", 900)) # e.g., 900W
|
||||
defrost_mode = bool(data.get("defrost_mode", False)) # True for defrost, False for cook/reheat
|
||||
print("Parsed parameters - Height (cm):", height_cm, "Initial Temp (C):", initial_temp_c, "Microwave Wattage:", microwave_wattage, "Defrost Mode:", defrost_mode)
|
||||
|
||||
# 1. Handle the Camera Image
|
||||
camera_image_b64 = data.get("camera_image")
|
||||
filepath = None
|
||||
|
||||
if camera_image_b64:
|
||||
# Generate a unique filename using UUID to avoid overwriting
|
||||
filename = f"dish_{uuid.uuid4().hex}.jpg"
|
||||
filepath = os.path.join(CAMERA_IMAGE_DIR, filename)
|
||||
|
||||
try:
|
||||
# Decode the base64 string and save it as a binary file
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(base64.b64decode(camera_image_b64))
|
||||
|
||||
# Replace the giant base64 string in the dictionary with the local file path
|
||||
# so we don't bloat the MongoDB document
|
||||
data["camera_image"] = filepath
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({"error": f"Failed to save camera image: {str(e)}"}), 500
|
||||
else:
|
||||
return jsonify({"error": "Missing required field 'camera_image'"}), 400
|
||||
|
||||
# 2. Save to MongoDB
|
||||
# 2. Run the Cook Planning Engine
|
||||
try:
|
||||
cook_plan = microwave_cook_planner.generate_plan(
|
||||
image_path=filepath,
|
||||
height_cm=height_cm,
|
||||
initial_temp_c=initial_temp_c,
|
||||
microwave_wattage=microwave_wattage,
|
||||
defrost_mode=defrost_mode
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"error": f"Failed to compute cooking plan: {str(e)}"}), 500
|
||||
|
||||
# 3. Attach cooking parameters to database record
|
||||
data["analysis_results"] = cook_plan
|
||||
|
||||
# 4. Save to MongoDB
|
||||
try:
|
||||
# Insert the dictionary directly into Mongo (it will retain your exact JSON keys)
|
||||
cooking_collection.insert_one(data)
|
||||
|
||||
# Remove the Mongo-injected '_id' object before returning the response
|
||||
data.pop("_id", None)
|
||||
return jsonify({"message": "Cooking parameters saved successfully", "data": data}), 201
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({"error": f"Database error: {str(e)}"}), 500
|
||||
|
||||
# 3. Returns with the cooking parameters
|
||||
|
||||
|
||||
# 5. Return complete output
|
||||
return jsonify(cook_plan), 201
|
||||
|
||||
@app.route("/device-network", methods=["POST"])
|
||||
def device_network():
|
||||
@@ -92,7 +120,12 @@ def device_network():
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({"error": f"Database error: {str(e)}"}), 500
|
||||
|
||||
|
||||
@app.route("/debug", methods=["GET"])
|
||||
def debug():
|
||||
image_path = "microwaveDish.jpg"
|
||||
edamam = EdamamAPI()
|
||||
return edamam.analyze_dish_image(image_path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=True)
|
||||
app.run(debug=config.DEBUG)
|
||||
@@ -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
|
||||
+138
-28
@@ -1,6 +1,13 @@
|
||||
import _thread
|
||||
from machine import Pin
|
||||
from shared import get_lora, get_uart, deviceTypes, config
|
||||
from machine import Pin, SoftI2C
|
||||
from shared.safeQueue import SafeQueue
|
||||
from shared import get_lora, get_uart, deviceTypes, config, cookingState
|
||||
from shared.uart_comm import UARTCommand, UARTCommandType
|
||||
from shared.sensors import RGBLED
|
||||
from shared.logging import log
|
||||
from shared.lora_device import LoraCommands
|
||||
import framebuf
|
||||
import ssd1306
|
||||
import time
|
||||
|
||||
# --- Configuration Matérielle ---
|
||||
@@ -18,52 +25,155 @@ except Exception:
|
||||
# --- Initialisation LoRa ---
|
||||
lora = get_lora()
|
||||
lora.configure(freq=868.1, sf=7)
|
||||
data_queue = SafeQueue()
|
||||
|
||||
# --- Création des lEDs RGB ---
|
||||
magnetron_led = RGBLED(red_pin=48, green_pin=47, blue_pin=33)
|
||||
magnetron_led.color = RGBLED.WHITE_YELLOW
|
||||
magnetron_led.off()
|
||||
|
||||
# --- Création de l'écran OLED ---
|
||||
scl_pin = Pin(18, Pin.OUT, pull=Pin.PULL_UP)
|
||||
sda_pin = Pin(17, Pin.OUT, pull=Pin.PULL_UP)
|
||||
display_i2c = SoftI2C(scl=scl_pin, sda=sda_pin, freq=100000)
|
||||
display = ssd1306.SSD1306_I2C(128, 64, display_i2c, addr=0x3C)
|
||||
display.text("Booting...", 1, 2, 1)
|
||||
display.show()
|
||||
|
||||
print(f"ESP32 initialisé avec l'ID : '{DEVICE_ID}' (Type : {deviceTypes.DEVICE_TYPES['MICROWAVE']})")
|
||||
|
||||
PING_PAYLOAD = {
|
||||
"id": DEVICE_ID,
|
||||
"type": deviceTypes.DEVICE_TYPES["MICROWAVE"]
|
||||
}
|
||||
|
||||
def heartbeat_loop():
|
||||
last_heartbeat_time = 0
|
||||
while True:
|
||||
print(f"\nESP32 : Envoi du Heartbeat...")
|
||||
# Envoi périodique
|
||||
ping_payload = {
|
||||
"id": DEVICE_ID,
|
||||
"type": deviceTypes.DEVICE_TYPES["MICROWAVE"]
|
||||
}
|
||||
lora.send(ping_payload)
|
||||
now = time.time()
|
||||
|
||||
# Le receive_packet est maintenant protégé par le lock dans lora_device
|
||||
# Si le main thread utilise la radio, ce thread attendra son tour
|
||||
paquet = lora.receive_packet(timeout_ms=2000)
|
||||
# 1. Send periodic heartbeat
|
||||
if now - last_heartbeat_time >= config.LORA_HEARTBEAT_INTERVAL:
|
||||
last_heartbeat_time = now
|
||||
print("\nESP32 : Envoi du Heartbeat...")
|
||||
lora.send(PING_PAYLOAD)
|
||||
|
||||
if paquet and not paquet["raw"]:
|
||||
donnees = paquet["data"]
|
||||
# Vérification si le paquet reçu est bien la réponse attendue de l'orchestrateur
|
||||
if donnees.get("type") == deviceTypes.DEVICE_TYPES["ORCHESTRATOR"]:
|
||||
print(f"ESP32 : Réponse reçue de l'orchestrateur '{donnees.get('id')}' ! [Statut: ALIVE]")
|
||||
else:
|
||||
print(f"ESP32 : Paquet reçu d'un type inattendu : {donnees.get('type')}")
|
||||
else:
|
||||
print("ESP32 : Pas de réponse de l'orchestrateur (Le RPI est-il éteint ?)")
|
||||
# 2. Increase listen window to 300ms so radio stays active in RX mode
|
||||
paquet = lora.receive_reliable(timeout_ms=300)
|
||||
|
||||
time.sleep(config.HEARTBEAT_INTERVAL)
|
||||
if paquet is not None:
|
||||
log(f"[LoRa Thread] New Packet Received: {paquet}")
|
||||
data_queue.put(paquet)
|
||||
|
||||
time.sleep_ms(10)
|
||||
|
||||
# UART
|
||||
uart_device = get_uart(uart_id=1, tx_pin=46, rx_pin=45)
|
||||
|
||||
# Lancer la boucle de heartbeat dans un thread séparé
|
||||
try:
|
||||
_thread.stack_size(16 * 1024)
|
||||
except Exception:
|
||||
pass
|
||||
_thread.start_new_thread(heartbeat_loop, ())
|
||||
|
||||
# Cooking parameters
|
||||
cooking_state = None
|
||||
def cooking_state_temperature_provider():
|
||||
return 22.0, 29.0 # TODO Remplacer par la lecture réelle de la température du plat et de l'air ambiant
|
||||
|
||||
def cooking_state_on_state_change(state):
|
||||
print(f"[Main] Cooking state changed to: {state.state}")
|
||||
|
||||
# Send to the Wifi board the current state
|
||||
uart_device.send_as_command(UARTCommand(UARTCommandType.COOKING_STATE_UPDATE, {"state": state.state}))
|
||||
# Send to the orchestrator the current state
|
||||
lora.send_reliable({"id": DEVICE_ID, "new_cooking_state": state.state})
|
||||
|
||||
display.text(cookingState.CookingStates.get_state_name(state.state), 1, 2, 1)
|
||||
display.show()
|
||||
|
||||
if state.paused or state.state == cookingState.CookingStates.DONE or state.state == cookingState.CookingStates.IDLE:
|
||||
magnetron_led.off()
|
||||
else:
|
||||
magnetron_led.on()
|
||||
|
||||
|
||||
if state.state == cookingState.CookingStates.COOKING:
|
||||
pass
|
||||
if state.state == cookingState.CookingStates.STIRRING_REQUIRED:
|
||||
pass
|
||||
if state.state == cookingState.CookingStates.DONE:
|
||||
pass
|
||||
if state.state == cookingState.CookingStates.ALERT:
|
||||
pass
|
||||
|
||||
def cooking_state_on_refresh(state):
|
||||
# TODO Show screen information
|
||||
pass
|
||||
|
||||
def cooking_state_on_pause(state):
|
||||
# If the cooking is unpaused and was in STIRRING_REQUIRED or ALERT state, we set the state back to COOKING.
|
||||
if not state.paused and (state.state == cookingState.CookingStates.STIRRING_REQUIRED or state.state == cookingState.CookingStates.ALERT):
|
||||
state.set_state(cookingState.CookingStates.COOKING)
|
||||
|
||||
# TODO send_reliable lora message to orchestrator about pause/resume state
|
||||
|
||||
|
||||
# --- MAIN APPLICATION THREAD ---
|
||||
print("[Main] Main execution path active.")
|
||||
while True:
|
||||
# 1. Listen for incoming UART serial packets from the WROOM board
|
||||
while uart_device.any():
|
||||
command = uart_device.read()
|
||||
print(f"[Main] Received command from WiFi Board: {command}")
|
||||
|
||||
command = uart_device.read_as_command()
|
||||
if command:
|
||||
print(f"[Main] Received command from WiFi Board: {command.command_type}")
|
||||
if command.command_type == UARTCommandType.COOKING_PARAMS:
|
||||
# Handle cooking parameters command
|
||||
params = command.payload
|
||||
print(f"[Main] Cooking parameters received: {params}")
|
||||
cooking_state = cookingState.CookingState(
|
||||
cook_time=params["cook_time"],
|
||||
power_level=params["power_level"],
|
||||
target_temp=params["target_temp"]
|
||||
)
|
||||
cooking_state.set_temperature_provider(cooking_state_temperature_provider)
|
||||
cooking_state.set_state_change_callback(cooking_state_on_state_change)
|
||||
cooking_state.set_refresh_callback(cooking_state_on_refresh)
|
||||
cooking_state.set_pause_callback(cooking_state_on_pause)
|
||||
time.sleep_ms(20) # Before sending back right away
|
||||
cooking_state_on_state_change(cooking_state)
|
||||
|
||||
else:
|
||||
print(f"[Main] Unknown command type received: {command.command_type}")
|
||||
# 2. Listen for incoming LoRa packets from the orchestrator
|
||||
while not data_queue.empty():
|
||||
paquet = data_queue.get()
|
||||
if paquet and not paquet["raw"]:
|
||||
data = paquet["data"]
|
||||
# Commands
|
||||
if "action" in data:
|
||||
if data["action"] == LoraCommands.TOGGLE_PAUSE:
|
||||
if cooking_state != None:
|
||||
if (cooking_state.state == cookingState.CookingStates.DONE):
|
||||
print("[Main] Cooking is done. We reset the microwave for the next cooking session.")
|
||||
cooking_state.set_state(cookingState.CookingStates.IDLE)
|
||||
time.sleep_ms(20) # Before sending back right away
|
||||
cooking_state = None
|
||||
else:
|
||||
cooking_state.toggle_pause()
|
||||
if cooking_state.paused:
|
||||
print("[Main] Cooking paused via orchestrator command.")
|
||||
else:
|
||||
print("[Main] Cooking resumed via orchestrator command.")
|
||||
else:
|
||||
log("[Main] No active cooking state to toggle pause/resume.")
|
||||
|
||||
# uart_device.send(f"Hello from esp-32 lora ID {DEVICE_ID}")
|
||||
|
||||
# 2. Send local metrics over the wire to the WiFi board every few seconds
|
||||
# uart_device.send("Data Pack: LoRa Link RSSI -72dBm")
|
||||
# Cooking State Update
|
||||
if cooking_state != None:
|
||||
cooking_state.update_tick()
|
||||
print(f"[Main] Cooking state : State : {cooking_state.state}, Temperature: {cooking_state.current_dish_temp}, Paused: {cooking_state.paused}, Remaining Time: {cooking_state.get_remaining_time():.2f}s, Estimated Remaining Time: {cooking_state.get_remaining_time_estimation():.2f}s")
|
||||
|
||||
time.sleep_ms(200)
|
||||
time.sleep_ms(500)
|
||||
@@ -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
|
||||
from machine import Pin
|
||||
esp.osdebug(True)
|
||||
#import webrepl
|
||||
#webrepl.start()
|
||||
|
||||
def do_connect(ssid, pwd):
|
||||
import network
|
||||
sta_if = network.WLAN(network.STA_IF)
|
||||
if not sta_if.isconnected():
|
||||
print('connecting to network...')
|
||||
sta_if.active(True)
|
||||
sta_if.connect(ssid, pwd)
|
||||
while not sta_if.isconnected():
|
||||
pass
|
||||
print('network config:', sta_if.ifconfig())
|
||||
# def do_connect(ssid, pwd):
|
||||
# import network
|
||||
# sta_if = network.WLAN(network.STA_IF)
|
||||
# sta_if.config(pm=sta_if.PM_NONE)
|
||||
# if not sta_if.isconnected():
|
||||
# print('connecting to network...')
|
||||
# sta_if.active(True)
|
||||
# sta_if.connect(ssid, pwd)
|
||||
# while not sta_if.isconnected():
|
||||
# pass
|
||||
# print('network config:', sta_if.ifconfig())
|
||||
|
||||
import network
|
||||
import time
|
||||
|
||||
def do_connect(ssid, password):
|
||||
wlan = network.WLAN(network.STA_IF)
|
||||
|
||||
# 1. ALWAYS activate the interface FIRST
|
||||
if not wlan.active():
|
||||
wlan.active(True)
|
||||
|
||||
# 2. Configure Wi-Fi options AFTER activation
|
||||
try:
|
||||
# Disable Wi-Fi modem sleep (0 = PM_NONE)
|
||||
wlan.config(pm=0)
|
||||
except Exception as e:
|
||||
print("[Wi-Fi] Warning: Failed to set power management:", e)
|
||||
|
||||
# 3. Connect to the access point
|
||||
if not wlan.isconnected():
|
||||
print(f"[Wi-Fi] Connecting to {ssid}...")
|
||||
wlan.connect(ssid, password)
|
||||
|
||||
timeout = 15
|
||||
start_time = time.time()
|
||||
while not wlan.isconnected():
|
||||
if time.time() - start_time > timeout:
|
||||
print("[Wi-Fi] Connection timed out!")
|
||||
return False
|
||||
time.sleep(0.5)
|
||||
|
||||
print("[Wi-Fi] Connected! Network config:", wlan.ifconfig())
|
||||
return True
|
||||
|
||||
# Attempt to connect to WiFi network
|
||||
# do_connect("Smartwave-1", 'Smartwave-prot-1')
|
||||
do_connect("Smartwave-1", 'Smartwave-prot-1')
|
||||
|
||||
# Set PIN 27 as GND for the temperature sensor (MLX90614)
|
||||
sensor_gnd = Pin(27, Pin.OUT)
|
||||
sensor_gnd.value(0)
|
||||
+314
-141
@@ -1,28 +1,12 @@
|
||||
import _thread
|
||||
import select
|
||||
from machine import Pin
|
||||
from sensors import temperature_gun
|
||||
from shared import get_mqtt_client, get_uart, config, payloads
|
||||
import gc
|
||||
import sys
|
||||
import time
|
||||
import ujson as json
|
||||
import sys
|
||||
import uasyncio as asyncio
|
||||
from machine import Pin, I2C
|
||||
|
||||
# Simple thread-safe queue list
|
||||
msg_queue = []
|
||||
queue_lock = _thread.allocate_lock()
|
||||
|
||||
def queue_publish(topic, payload):
|
||||
"""Safely queues a message from the main thread."""
|
||||
with queue_lock:
|
||||
msg_queue.append((topic, payload))
|
||||
|
||||
# --- INITIALIZE CAMERA ---
|
||||
try:
|
||||
# Pass your confirmed working SCL and SDA pins here
|
||||
temperature_gun.init_camera(scl_pin=21, sda_pin=22, freq=100000)
|
||||
except Exception as e:
|
||||
print("[Main] Critical: Camera setup failed!")
|
||||
sys.print_exception(e)
|
||||
# 1. Clean memory immediately before performing any operations
|
||||
gc.collect()
|
||||
|
||||
# --- READ DEVICE ID ---
|
||||
try:
|
||||
@@ -30,152 +14,341 @@ try:
|
||||
DEVICE_ID = f.read().strip()
|
||||
except Exception:
|
||||
DEVICE_ID = "ESP32_Inconnu"
|
||||
|
||||
|
||||
# --- GLOBAL APP STATE ---
|
||||
orchestrator_id = None
|
||||
cooking_state = None
|
||||
mqtt_connected = False
|
||||
should_unsubscribe_hello = False
|
||||
|
||||
# --- ASYNC SIGNALS & QUEUES ---
|
||||
# Event to signal when orchestrator requests sensor data (prevents MQTT lock deadlock)
|
||||
sensor_request_event = None
|
||||
|
||||
# --- MQTT SETUP ---
|
||||
from shared import get_mqtt_client, config, payloads
|
||||
|
||||
MQTT_CA_FILE = "/certs/ca.crt"
|
||||
|
||||
mqtt_client = get_mqtt_client(
|
||||
host=config.MQTT_BROKER_HOST,
|
||||
client_id="smartwave-esp32-" + DEVICE_ID,
|
||||
use_tls=config.USE_TLS,
|
||||
host="192.168.50.1",
|
||||
client_id="smartwave-esp32-demo",
|
||||
use_tls=True,
|
||||
cafile=MQTT_CA_FILE,
|
||||
keepalive=config.MQTT_KEEPALIVE,
|
||||
keepalive=30,
|
||||
)
|
||||
|
||||
global orchestrator_id
|
||||
orchestrator_id = None
|
||||
# --- HARDWARE & MODULE DEFERRED IMPORTS ---
|
||||
status_led = None
|
||||
uart_device = None
|
||||
mlx_temperature_sensor = None
|
||||
cookingState = None
|
||||
log = None
|
||||
UARTCommand = None
|
||||
UARTCommandType = None
|
||||
|
||||
|
||||
def init_hardware():
|
||||
"""Initializes hardware peripherals AFTER MQTT TLS has reserved its RAM."""
|
||||
global status_led, uart_device, mlx_temperature_sensor
|
||||
global cookingState, log, UARTCommand, UARTCommandType
|
||||
|
||||
print("[Main] Initializing hardware peripherals...")
|
||||
|
||||
from shared import get_uart, cookingState as cs, logging
|
||||
from shared.uart_comm import UARTCommand as UC, UARTCommandType as UCT
|
||||
from shared.sensors import RGBLED
|
||||
from sensors import temperature_sensor
|
||||
|
||||
cookingState = cs
|
||||
log = logging.log
|
||||
UARTCommand = UC
|
||||
UARTCommandType = UCT
|
||||
|
||||
status_led = RGBLED(red_pin=21, green_pin=19, blue_pin=18)
|
||||
uart_device = get_uart(uart_id=2, tx_pin=17, rx_pin=16)
|
||||
|
||||
temperature_sensor_i2c = I2C(
|
||||
0,
|
||||
scl=Pin(25, Pin.IN, Pin.PULL_UP),
|
||||
sda=Pin(26, Pin.IN, Pin.PULL_UP),
|
||||
freq=100000,
|
||||
)
|
||||
devices = temperature_sensor_i2c.scan()
|
||||
if 0x5A in devices:
|
||||
print("[Main] MLX90614 found at address 0x5A!")
|
||||
else:
|
||||
print("[Main] MLX90614 not found on I2C bus.")
|
||||
mlx_temperature_sensor = temperature_sensor.MLX90614(temperature_sensor_i2c)
|
||||
|
||||
|
||||
def on_received_cooking_state_update(state, is_error=False, is_terminated=False):
|
||||
"""Callback executed when state changes are received from the LoRa board over UART."""
|
||||
if cooking_state:
|
||||
if is_error:
|
||||
cooking_state.set_state(cookingState.CookingStates.ERROR)
|
||||
elif is_terminated:
|
||||
cooking_state.set_state(cookingState.CookingStates.ABORTED)
|
||||
else:
|
||||
cooking_state.set_state(state)
|
||||
|
||||
|
||||
def on_cooking_state_change(state):
|
||||
"""Callback executed whenever local cooking state transitions."""
|
||||
BLINK_INTERVAL_MS = 500
|
||||
|
||||
if status_led and cookingState:
|
||||
if state == cookingState.CookingStates.IDLE:
|
||||
status_led.color = status_led.OFF
|
||||
status_led.blink_off()
|
||||
elif state == cookingState.CookingStates.COOKING:
|
||||
status_led.color = status_led.YELLOW
|
||||
status_led.blink_off()
|
||||
elif state == cookingState.CookingStates.STIRRING_REQUIRED:
|
||||
status_led.color = status_led.ORANGE
|
||||
status_led.blink_on(BLINK_INTERVAL_MS)
|
||||
elif state == cookingState.CookingStates.ALERT:
|
||||
status_led.color = status_led.RED
|
||||
status_led.blink_on(BLINK_INTERVAL_MS)
|
||||
elif state == cookingState.CookingStates.DONE:
|
||||
status_led.color = status_led.GREEN
|
||||
status_led.blink_off()
|
||||
|
||||
|
||||
def on_mqtt_message(message):
|
||||
print("[MQTT Thread] Received message:", message)
|
||||
|
||||
# Try and parse the payload as json, but if it fails, just print the raw payload
|
||||
payload_data=None
|
||||
"""Sync callback: Lightweight! Only updates variables or triggers async signals."""
|
||||
global orchestrator_id, cooking_state, should_unsubscribe_hello
|
||||
print("[MQTT] Received message on topic:", message.get("topic"))
|
||||
|
||||
payload_data = None
|
||||
try:
|
||||
payload_data = json.loads(message['payload'])
|
||||
payload_data = json.loads(message["payload"])
|
||||
except Exception as e:
|
||||
print("[MQTT Thread] Error parsing JSON:", e)
|
||||
sys.print_exception(e)
|
||||
pass # Maybe it's not JSON
|
||||
|
||||
if message['topic'] == config.MQTT_TOPIC_HELLO and payload_data and "id_orchestrator" in payload_data and payload_data["id_microwave"] == DEVICE_ID:
|
||||
print("[MQTT Thread] Hello response received from orchestrator:", payload_data["id_orchestrator"])
|
||||
global orchestrator_id
|
||||
orchestrator_id = payload_data["id_orchestrator"]
|
||||
# Unsubscribe from the hello topic since we got a response
|
||||
mqtt_client.unsubscribe(config.MQTT_TOPIC_HELLO)
|
||||
print("[MQTT Thread] Unsubscribed from topic:", config.MQTT_TOPIC_HELLO)
|
||||
print("[MQTT Thread] Message processing complete.")
|
||||
print("[MQTT] Payload parsing warning:", e)
|
||||
|
||||
mqtt_client.set_callback(on_mqtt_message)
|
||||
topic = message.get("topic")
|
||||
|
||||
# 1. Orchestrator Hello Response
|
||||
if (
|
||||
topic == config.MQTT_TOPIC_HELLO
|
||||
and payload_data
|
||||
and payload_data.get("id_microwave") == DEVICE_ID
|
||||
):
|
||||
orchestrator_id = payload_data.get("id_orchestrator")
|
||||
print("[MQTT] Hello response received from orchestrator:", orchestrator_id)
|
||||
should_unsubscribe_hello = True
|
||||
|
||||
# 2. Cooking Parameters / Sensor Request
|
||||
elif (
|
||||
topic == config.MQTT_TOPIC_COOKING
|
||||
and payload_data
|
||||
and payload_data.get("id_microwave") == DEVICE_ID
|
||||
):
|
||||
if "cook_time" not in payload_data:
|
||||
print("[MQTT] Sensor data requested! Triggering async publisher...")
|
||||
# Trigger async event instead of calling publish() directly inside lock context!
|
||||
sensor_request_event.set()
|
||||
else:
|
||||
print("[MQTT] Cooking parameters received:", payload_data)
|
||||
if cookingState:
|
||||
cooking_state = cookingState.CookingState(
|
||||
cook_time=payload_data["cook_time"],
|
||||
power_level=payload_data["power_level"],
|
||||
target_temp=payload_data["target_temp"],
|
||||
)
|
||||
cooking_state.set_state_change_callback(on_cooking_state_change)
|
||||
cooking_state.set_state(cookingState.CookingStates.IDLE)
|
||||
|
||||
if uart_device and UARTCommand:
|
||||
uart_device.send_as_command(
|
||||
UARTCommand(UARTCommandType.COOKING_PARAMS, payload_data)
|
||||
)
|
||||
print("[MQTT] Cooking parameters sent to LoRa board over UART.")
|
||||
|
||||
|
||||
def mqtt_background_thread():
|
||||
"""Background MQTT worker handling ALL socket operations safely."""
|
||||
print("[Thread] Background MQTT worker started.")
|
||||
|
||||
# --- DEDICATED ASYNC TASK FOR SENSOR PUBLISHING ---
|
||||
async def sensor_publisher_task():
|
||||
"""Waits for sensor_request_event, reads hardware, and publishes outside the MQTT lock."""
|
||||
while True:
|
||||
await sensor_request_event.wait()
|
||||
sensor_request_event.clear()
|
||||
|
||||
print("[Sensor Task] Reading temperature sensors...")
|
||||
obj_temp = (
|
||||
mlx_temperature_sensor.read_object_temp()
|
||||
if mlx_temperature_sensor
|
||||
else 0
|
||||
)
|
||||
amb_temp = (
|
||||
mlx_temperature_sensor.read_ambient_temp()
|
||||
if mlx_temperature_sensor
|
||||
else 0
|
||||
)
|
||||
|
||||
sensor_payload = payloads.mqtt_sensor_data(DEVICE_ID, obj_temp, amb_temp)
|
||||
|
||||
try:
|
||||
print("[Sensor Task] Publishing sensor data to MQTT...")
|
||||
mqtt_client.publish(
|
||||
config.MQTT_TOPIC_SENSOR, sensor_payload, qos=config.MQTT_QOS
|
||||
)
|
||||
print("[Sensor Task] Sensor data successfully published:", sensor_payload)
|
||||
except Exception as e:
|
||||
print("[Sensor Task] Failed to publish sensor data:", e)
|
||||
|
||||
|
||||
async def uart_task():
|
||||
"""Polls incoming UART messages from the LoRa board using dynamic method fallback."""
|
||||
while True:
|
||||
if uart_device:
|
||||
try:
|
||||
cmd = uart_device.read_as_command()
|
||||
|
||||
if cmd:
|
||||
print("[UART] Command received from LoRa board:", cmd)
|
||||
if (
|
||||
hasattr(cmd, "command_type")
|
||||
and cmd.command_type == UARTCommandType.STATE_UPDATE
|
||||
and on_received_cooking_state_update
|
||||
):
|
||||
on_received_cooking_state_update(
|
||||
cmd.payload.get("state"),
|
||||
cmd.payload.get("is_error", False),
|
||||
cmd.payload.get("is_terminated", False),
|
||||
)
|
||||
except Exception as e:
|
||||
print("[UART Task] Error reading command:", e)
|
||||
|
||||
await asyncio.sleep_ms(50)
|
||||
|
||||
|
||||
async def connect_mqtt_async():
|
||||
global mqtt_connected, mqtt_client
|
||||
mqtt_connected = False
|
||||
|
||||
while True:
|
||||
try:
|
||||
print("[Thread] Attempting connection to MQTT broker...")
|
||||
print("[MQTT] Connecting to broker with TLS...")
|
||||
# Re-instantiate client to clear old socket buffers
|
||||
gc.collect()
|
||||
mqtt_client = get_mqtt_client(
|
||||
host="192.168.50.1", # TODO : Use config.MQTT_BROKER_HOST instead of hardcoding
|
||||
port=8884,
|
||||
client_id="smartwave-esp32-demo",
|
||||
use_tls=True,
|
||||
cafile=MQTT_CA_FILE,
|
||||
keepalive=30,
|
||||
)
|
||||
mqtt_client.set_callback(on_mqtt_message)
|
||||
|
||||
mqtt_client.connect()
|
||||
print("[Thread] Connected! Subscribing to topic...")
|
||||
print("[MQTT] Connected! Subscribing to topics...")
|
||||
mqtt_client.subscribe(config.MQTT_TOPIC_COOKING, qos=config.MQTT_QOS)
|
||||
print("[Thread] Successfully subscribed. Setting up poller...")
|
||||
|
||||
poller = select.poll()
|
||||
poller.register(mqtt_client._client.sock, select.POLLIN)
|
||||
|
||||
last_check = time.time()
|
||||
|
||||
while True:
|
||||
# 1. Process outbound messages queued by the main thread
|
||||
while len(msg_queue) > 0:
|
||||
with queue_lock:
|
||||
topic, payload = msg_queue.pop(0)
|
||||
print(f"[Thread] Safely publishing queued message to {topic}...")
|
||||
mqtt_client.publish(topic, payload, qos=config.MQTT_QOS)
|
||||
|
||||
# 2. Check for incoming messages (non-blocking poll)
|
||||
# Shortened timeout to keep the queue responsive
|
||||
events = poller.poll(200)
|
||||
if events:
|
||||
mqtt_client.wait()
|
||||
|
||||
# 3. Handle Keepalive tracking manually
|
||||
if time.time() - last_check >= 15:
|
||||
# print("[Thread] Sending keepalive ping...")
|
||||
mqtt_client._client.ping()
|
||||
last_check = time.time()
|
||||
|
||||
# Small breathe room for the CPU core
|
||||
time.sleep_ms(50)
|
||||
|
||||
mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS)
|
||||
print("[MQTT] Subscribed successfully!")
|
||||
mqtt_connected = True
|
||||
return
|
||||
except Exception as e:
|
||||
print("[Thread] Connection dropped or error encountered:", e)
|
||||
print("[MQTT] Connection failed:", e)
|
||||
sys.print_exception(e)
|
||||
print("[Thread] Cleaning up socket context. Retrying in 5 seconds...")
|
||||
|
||||
# --- FIX FOR ERROR 23 (SOCKET LEAK) ---
|
||||
# Manually force-kill the underlying socket file descriptor if it exists
|
||||
try:
|
||||
if mqtt_client._client and hasattr(mqtt_client._client, "sock"):
|
||||
if mqtt_client._client.sock is not None:
|
||||
mqtt_client._client.sock.close()
|
||||
except Exception:
|
||||
pass # Already dead or closed
|
||||
|
||||
# Now we let the wrapper do its normal cleanup safely
|
||||
try:
|
||||
mqtt_client.close()
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(5)
|
||||
|
||||
# Force heap cleanup before sleeping
|
||||
del mqtt_client
|
||||
gc.collect()
|
||||
print(f"[MQTT] Free RAM after cleanup: {gc.mem_free()} bytes")
|
||||
print("[MQTT] Retrying connection in 5 seconds...")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def mqtt_poll_task():
|
||||
global mqtt_connected
|
||||
last_ping = time.time()
|
||||
|
||||
# --- UART BACKGROUND THREAD ---
|
||||
def uart_background_thread():
|
||||
"""Background UART worker handling all serial operations safely."""
|
||||
print("[Thread] Background UART worker started.")
|
||||
|
||||
uart_device = get_uart(uart_id=2, tx_pin=17, rx_pin=16)
|
||||
|
||||
while True:
|
||||
try:
|
||||
# 1. Check for incoming messages from the Heltec board
|
||||
while uart_device.any():
|
||||
incoming_msg = uart_device.read()
|
||||
print(f"[Thread] Received from esp-lora over UART: {incoming_msg}")
|
||||
if mqtt_connected:
|
||||
try:
|
||||
mqtt_client.poll()
|
||||
now = time.time()
|
||||
if now - last_ping >= 15:
|
||||
mqtt_client.ping()
|
||||
last_ping = now
|
||||
except OSError as e:
|
||||
print("[MQTT Task] Socket error encountered during poll/ping:", e)
|
||||
mqtt_connected = False
|
||||
await connect_mqtt_async()
|
||||
|
||||
await asyncio.sleep_ms(30)
|
||||
|
||||
|
||||
async def orchestrator_hello_task():
|
||||
global mqtt_connected, should_unsubscribe_hello
|
||||
while True:
|
||||
if orchestrator_id is not None:
|
||||
if should_unsubscribe_hello:
|
||||
try:
|
||||
mqtt_client.unsubscribe(config.MQTT_TOPIC_HELLO)
|
||||
should_unsubscribe_hello = False
|
||||
print("[MQTT] Successfully unsubscribed from hello topic.")
|
||||
except Exception as e:
|
||||
print("[MQTT] Unsubscribe error:", e)
|
||||
|
||||
# Hello successfully acknowledged! Stop looping this task.
|
||||
print("[Hello Task] Orchestrator acknowledged. Stopping hello task.")
|
||||
break
|
||||
|
||||
# 2. Example: Send data to the Heltec board every 5 seconds
|
||||
# uart_device.send("Status Check: WiFi Active")
|
||||
|
||||
time.sleep(5) # Fast responsive polling loop for local UART
|
||||
|
||||
except Exception as e:
|
||||
print("[Thread] UART error encountered:", e)
|
||||
time.sleep(5)
|
||||
if mqtt_connected:
|
||||
print("[Hello Task] Sending initial hello to orchestrator...")
|
||||
try:
|
||||
if mqtt_client is None:
|
||||
print("[Hello Task] MQTT client is None. Attempting to reconnect...")
|
||||
await connect_mqtt_async()
|
||||
|
||||
mqtt_client.publish(
|
||||
config.MQTT_TOPIC_HELLO,
|
||||
payloads.mqtt_hello(DEVICE_ID),
|
||||
qos=config.MQTT_QOS,
|
||||
)
|
||||
except OSError as e:
|
||||
print("[Hello Task] Hello publish failed:", e)
|
||||
# mqtt_connected = False
|
||||
|
||||
# --- Launch background worker ---
|
||||
# _thread.start_new_thread(mqtt_background_thread, ())
|
||||
# _thread.start_new_thread(uart_background_thread, ())
|
||||
await asyncio.sleep(config.MQTT_HELLO_INTERVAL)
|
||||
|
||||
|
||||
# --- MAIN APPLICATION THREAD (Core 0) ---
|
||||
print("[Main] Main execution path active.")
|
||||
time.sleep(2) # Give the thread a moment to initial connect
|
||||
mqtt_hello_sent_timestamp = -config.MQTT_HELLO_INTERVAL
|
||||
mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS)
|
||||
async def memory_cleanup_task():
|
||||
while True:
|
||||
gc.collect()
|
||||
await asyncio.sleep(10)
|
||||
|
||||
while True:
|
||||
# MQTT HELLO sent every x seconds until we get a response from the orchestrator
|
||||
if (orchestrator_id == None and -(mqtt_hello_sent_timestamp - time.time()) > config.MQTT_HELLO_INTERVAL):
|
||||
print("[Main] Attempting to send initial hello to orchestrator...")
|
||||
queue_publish(config.MQTT_TOPIC_HELLO, payloads.mqtt_hello(DEVICE_ID))
|
||||
mqtt_hello_sent_timestamp = time.time()
|
||||
pass
|
||||
|
||||
# Sensors
|
||||
print(f"[Main] Reading temperature from the gun sensor...")
|
||||
temp = temperature_gun.read_temperature()
|
||||
print(f"[Main] Temperature read: {temp}°C")
|
||||
|
||||
# 2. Example: Send data to the Heltec board every 5 seconds
|
||||
# uart_device.send("Status Check: WiFi Active")
|
||||
time.sleep(1)
|
||||
|
||||
# --- MAIN ENTRY POINT ---
|
||||
async def main():
|
||||
global sensor_request_event
|
||||
print("[Main] Starting application...")
|
||||
|
||||
# Initialize loop-bound events
|
||||
sensor_request_event = asyncio.Event()
|
||||
|
||||
await connect_mqtt_async()
|
||||
init_hardware()
|
||||
|
||||
# Launch background tasks
|
||||
asyncio.create_task(mqtt_poll_task())
|
||||
asyncio.create_task(orchestrator_hello_task())
|
||||
asyncio.create_task(sensor_publisher_task())
|
||||
asyncio.create_task(uart_task())
|
||||
asyncio.create_task(memory_cleanup_task())
|
||||
|
||||
print("[Main] All tasks running concurrently!")
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
print("[Main] Program stopped by user.")
|
||||
@@ -1 +1 @@
|
||||
import sensors.temperature_gun as temperature_gun
|
||||
import sensors.temperature_sensor as temperature_sensor
|
||||
@@ -1,969 +0,0 @@
|
||||
"""
|
||||
Temperatue gun sensor module
|
||||
using the MLX90640-D55/D110 sensor. This module provides a function to read the temperature from the gun sensor.
|
||||
Resolution of 32x24 pixels,
|
||||
I2C interface
|
||||
Noise Equivalent Temperature difference (NETD) is 0.1K RMS @ 1Hz refresh rate
|
||||
"""
|
||||
|
||||
import machine # type: ignore
|
||||
import math
|
||||
import struct
|
||||
import time
|
||||
from micropython import const# Some libraries that we will use
|
||||
import time
|
||||
|
||||
|
||||
class RefreshRate: # pylint: disable=too-few-public-methods
|
||||
""" Enum-like class for MLX90640's refresh rate """
|
||||
REFRESH_0_5_HZ = const(0b000) # 0.5Hz
|
||||
REFRESH_1_HZ = const(0b001) # 1Hz
|
||||
REFRESH_2_HZ = const(0b010) # 2Hz
|
||||
REFRESH_4_HZ = const(0b011) # 4Hz
|
||||
REFRESH_8_HZ = const(0b100) # 8Hz
|
||||
REFRESH_16_HZ = const(0b101) # 16Hz
|
||||
REFRESH_32_HZ = const(0b110) # 32Hz
|
||||
REFRESH_64_HZ = const(0b111) # 64Hz
|
||||
|
||||
class ContextManaged:
|
||||
"""An object that automatically deinitializes hardware with a context manager."""
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
self.deinit()
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
def deinit(self):
|
||||
"""Free any hardware used by the object."""
|
||||
return
|
||||
|
||||
class Lockable(ContextManaged):
|
||||
"""An object that must be locked to prevent collisions on a microcontroller resource."""
|
||||
|
||||
_locked = False
|
||||
|
||||
def try_lock(self):
|
||||
"""Attempt to grab the lock. Return True on success, False if the lock is already taken."""
|
||||
if self._locked:
|
||||
return False
|
||||
self._locked = True
|
||||
return True
|
||||
|
||||
def unlock(self):
|
||||
"""Release the lock so others may use the resource."""
|
||||
if self._locked:
|
||||
self._locked = False
|
||||
else:
|
||||
raise ValueError("Not locked")
|
||||
|
||||
class I2C(Lockable):
|
||||
def __init__(self, pins=(21, 22), frequency=100000):
|
||||
self.init(pins, frequency)
|
||||
|
||||
def init(self, pins, frequency):
|
||||
self.deinit()
|
||||
|
||||
# 1. Force the ESP32 to activate its internal pull-up resistors on these pins
|
||||
self._pins = (
|
||||
machine.Pin(int(pins[0]), machine.Pin.IN, machine.Pin.PULL_UP),
|
||||
machine.Pin(int(pins[1]), machine.Pin.IN, machine.Pin.PULL_UP)
|
||||
)
|
||||
|
||||
try:
|
||||
# 2. Bypasses the glitchy ESP32 hardware block using SoftI2C
|
||||
# (Note: SoftI2C does not take a bus ID number like '0')
|
||||
self._i2c = machine.SoftI2C(scl=self._pins[0], sda=self._pins[1], freq=frequency)
|
||||
except RuntimeError:
|
||||
raise
|
||||
print(f"Created resilient SoftI2C: {self._i2c}")
|
||||
|
||||
def deinit(self):
|
||||
try:
|
||||
del self._i2c
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
def scan(self):
|
||||
return self._i2c.scan()
|
||||
|
||||
def readfrom_into(self, address, buffer, *, start=0, end=None):
|
||||
if start is not 0 or end is not None:
|
||||
if end is None:
|
||||
end = len(buffer)
|
||||
buffer = memoryview(buffer)[start:end]
|
||||
stop = True # remove for efficiency later
|
||||
return self._i2c.readfrom_into(address, buffer)
|
||||
|
||||
def writeto(self, address, buffer, *, start=0, end=None, stop=True):
|
||||
if isinstance(buffer, str):
|
||||
buffer = bytes([ord(x) for x in buffer])
|
||||
if start is not 0 or end is not None:
|
||||
if end is None:
|
||||
return self._i2c.writeto(address, memoryview(buffer)[start:], stop)
|
||||
else:
|
||||
return self._i2c.writeto(address, memoryview(buffer)[start:end], stop)
|
||||
return self._i2c.writeto(address, buffer, stop)
|
||||
|
||||
class I2CDevice:
|
||||
def __init__(self, i2c, device_address, probe=True):
|
||||
self.i2c = i2c
|
||||
self._has_write_read = False # hasattr(self.i2c, "writeto_then_readfrom") --> has been turned to False
|
||||
self.device_address = device_address
|
||||
|
||||
if probe:
|
||||
self.__probe_for_device()
|
||||
|
||||
def readinto(self, buf, *, start=0, end=None):
|
||||
if end is None:
|
||||
end = len(buf)
|
||||
self.i2c.readfrom_into(self.device_address, buf, start=start, end=end)
|
||||
|
||||
def write(self, buf, *, start=0, end=None, stop=True):
|
||||
if end is None:
|
||||
end = len(buf)
|
||||
self.i2c.writeto(self.device_address, buf, start=start, end=end, stop=stop)
|
||||
|
||||
# pylint: disable-msg=too-many-arguments
|
||||
def write_then_readinto(
|
||||
self,
|
||||
out_buffer,
|
||||
in_buffer,
|
||||
*,
|
||||
out_start=0,
|
||||
out_end=None,
|
||||
in_start=0,
|
||||
in_end=None,
|
||||
stop=False
|
||||
):
|
||||
if out_end is None:
|
||||
out_end = len(out_buffer)
|
||||
if in_end is None:
|
||||
in_end = len(in_buffer)
|
||||
if stop:
|
||||
raise ValueError("Stop must be False. Use writeto instead.")
|
||||
if self._has_write_read:
|
||||
#print("c",dir(self.i2c))
|
||||
# In linux, at least, this is a special kernel function call
|
||||
self.i2c.writeto_then_readfrom(
|
||||
self.device_address,
|
||||
out_buffer,
|
||||
in_buffer,
|
||||
out_start=out_start,
|
||||
out_end=out_end,
|
||||
in_start=in_start,
|
||||
in_end=in_end,
|
||||
)
|
||||
|
||||
else:
|
||||
# If we don't have a special implementation, we can fake it with two calls
|
||||
self.i2c.writeto(self.device_address, out_buffer, stop=False) # These lines have been changed to make it work with wipy micropython I2C module
|
||||
#self.write(out_buffer, start=out_start, end=out_end, stop=False)
|
||||
#self.readinto(in_buffer, start=in_start, end=in_end)
|
||||
self.i2c.readfrom_into(self.device_address, in_buffer) # These lines have been changed to make it work with wipy micropython I2C module
|
||||
|
||||
|
||||
# pylint: enable-msg=too-many-arguments
|
||||
|
||||
def __enter__(self):
|
||||
while not self.i2c.try_lock():
|
||||
pass
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.i2c.unlock()
|
||||
return False
|
||||
|
||||
def __probe_for_device(self):
|
||||
"""
|
||||
Try to read a byte from an address,
|
||||
if you get an OSError it means the device is not there
|
||||
or that the device does not support these means of probing
|
||||
"""
|
||||
while not self.i2c.try_lock():
|
||||
pass
|
||||
try:
|
||||
self.i2c.writeto(self.device_address, b"")
|
||||
except OSError:
|
||||
# some OS's dont like writing an empty bytesting...
|
||||
# Retry by reading a byte
|
||||
try:
|
||||
result = bytearray(1)
|
||||
self.i2c.readfrom_into(self.device_address, result)
|
||||
except OSError:
|
||||
raise ValueError("No I2C device at address: %x" % self.device_address)
|
||||
finally:
|
||||
self.i2c.unlock()
|
||||
|
||||
eeData = [0] * const(832)
|
||||
I2C_READ_LEN = const(2048)
|
||||
SCALEALPHA = const(0.000001)
|
||||
MLX90640_DEVICEID1 = const(0x2407)
|
||||
OPENAIR_TA_SHIFT = const(8)
|
||||
|
||||
class MLX90640: # pylint: disable=too-many-instance-attributes
|
||||
"""Interface to the MLX90640 temperature sensor."""
|
||||
|
||||
kVdd = 0
|
||||
vdd25 = 0
|
||||
KvPTAT = 0
|
||||
KtPTAT = 0
|
||||
vPTAT25 = 0
|
||||
alphaPTAT = 0
|
||||
gainEE = 0
|
||||
tgc = 0
|
||||
KsTa = 0
|
||||
resolutionEE = 0
|
||||
calibrationModeEE = 0
|
||||
ksTo = [0] * 5
|
||||
ct = [0] * 5
|
||||
alpha = [0] * 768
|
||||
alphaScale = 0
|
||||
offset = [0] * 768
|
||||
kta = [0] * 768
|
||||
ktaScale = 0
|
||||
kv = [0] * 768
|
||||
kvScale = 0
|
||||
cpAlpha = [0] * 2
|
||||
cpOffset = [0] * 2
|
||||
ilChessC = [0] * 3
|
||||
brokenPixels = [0xFFFF] * 5
|
||||
outlierPixels = [0xFFFF] * 5
|
||||
cpKta = 0
|
||||
cpKv = 0
|
||||
|
||||
def __init__(self, i2c_bus, address=0x33):
|
||||
self.i2c_device = I2CDevice(i2c_bus, address)
|
||||
self._I2CReadWords(0x2400, eeData)
|
||||
# print(eeData)
|
||||
self._ExtractParameters()
|
||||
|
||||
@property
|
||||
def serial_number(self):
|
||||
""" 3-item tuple of hex values that are unique to each MLX90640 """
|
||||
serialWords = [0, 0, 0]
|
||||
self._I2CReadWords(MLX90640_DEVICEID1, serialWords)
|
||||
return serialWords
|
||||
|
||||
@property
|
||||
def refresh_rate(self):
|
||||
""" How fast the MLX90640 will spit out data. Start at lowest speed in
|
||||
RefreshRate and then slowly increase I2C clock rate and rate until you
|
||||
max out. The sensor does not like it if the I2C host cannot 'keep up'!"""
|
||||
controlRegister = [0]
|
||||
self._I2CReadWords(0x800D, controlRegister)
|
||||
return (controlRegister[0] >> 7) & 0x07
|
||||
|
||||
@refresh_rate.setter
|
||||
def refresh_rate(self, rate):
|
||||
controlRegister = [0]
|
||||
value = (rate & 0x7) << 7
|
||||
self._I2CReadWords(0x800D, controlRegister)
|
||||
value |= controlRegister[0] & 0xFC7F
|
||||
self._I2CWriteWord(0x800D, value)
|
||||
|
||||
def getFrame(self, framebuf):
|
||||
""" Request both 'halves' of a frame from the sensor, merge them
|
||||
and calculate the temperature in C for each of 32x24 pixels. Placed
|
||||
into the 768-element array passed in! """
|
||||
emissivity = 0.95
|
||||
tr = 23.15
|
||||
mlx90640Frame = [0] * 834
|
||||
|
||||
for _ in range(2):
|
||||
status = self._GetFrameData(mlx90640Frame)
|
||||
if status < 0:
|
||||
raise RuntimeError("Frame data error")
|
||||
# For a MLX90640 in the open air the shift is -8 degC.
|
||||
tr = self._GetTa(mlx90640Frame) - OPENAIR_TA_SHIFT
|
||||
self._CalculateTo(mlx90640Frame, emissivity, tr, framebuf)
|
||||
|
||||
def _GetFrameData(self, frameData):
|
||||
dataReady = 0
|
||||
cnt = 0
|
||||
statusRegister = [0]
|
||||
controlRegister = [0]
|
||||
|
||||
while dataReady == 0:
|
||||
self._I2CReadWords(0x8000, statusRegister)
|
||||
dataReady = statusRegister[0] & 0x0008
|
||||
# print("ready status: 0x%x" % dataReady)
|
||||
|
||||
while (dataReady != 0) and (cnt < 5):
|
||||
self._I2CWriteWord(0x8000, 0x0030)
|
||||
# print("Read frame", cnt)
|
||||
self._I2CReadWords(0x0400, frameData, end=832)
|
||||
|
||||
self._I2CReadWords(0x8000, statusRegister)
|
||||
dataReady = statusRegister[0] & 0x0008
|
||||
# print("frame ready: 0x%x" % dataReady)
|
||||
cnt += 1
|
||||
|
||||
if cnt > 4:
|
||||
raise RuntimeError("Too many retries")
|
||||
|
||||
self._I2CReadWords(0x800D, controlRegister)
|
||||
frameData[832] = controlRegister[0]
|
||||
frameData[833] = statusRegister[0] & 0x0001
|
||||
return frameData[833]
|
||||
|
||||
def _GetTa(self, frameData):
|
||||
vdd = self._GetVdd(frameData)
|
||||
|
||||
ptat = frameData[800]
|
||||
if ptat > 32767:
|
||||
ptat -= 65536
|
||||
|
||||
ptatArt = frameData[768]
|
||||
if ptatArt > 32767:
|
||||
ptatArt -= 65536
|
||||
ptatArt = (ptat / (ptat * self.alphaPTAT + ptatArt)) * math.pow(2, 18)
|
||||
|
||||
ta = ptatArt / (1 + self.KvPTAT * (vdd - 3.3)) - self.vPTAT25
|
||||
ta = ta / self.KtPTAT + 25
|
||||
return ta
|
||||
|
||||
def _GetVdd(self, frameData):
|
||||
vdd = frameData[810]
|
||||
if vdd > 32767:
|
||||
vdd -= 65536
|
||||
|
||||
resolutionRAM = (frameData[832] & 0x0C00) >> 10
|
||||
resolutionCorrection = math.pow(2, self.resolutionEE) / math.pow(
|
||||
2, resolutionRAM
|
||||
)
|
||||
vdd = (resolutionCorrection * vdd - self.vdd25) / self.kVdd + 3.3
|
||||
|
||||
return vdd
|
||||
|
||||
def _CalculateTo(self, frameData, emissivity, tr, result):
|
||||
# pylint: disable=too-many-locals, too-many-branches, too-many-statements
|
||||
subPage = frameData[833]
|
||||
alphaCorrR = [0] * 4
|
||||
irDataCP = [0, 0]
|
||||
|
||||
vdd = self._GetVdd(frameData)
|
||||
ta = self._GetTa(frameData)
|
||||
|
||||
ta4 = ta + 273.15
|
||||
ta4 = ta4 * ta4
|
||||
ta4 = ta4 * ta4
|
||||
tr4 = tr + 273.15
|
||||
tr4 = tr4 * tr4
|
||||
tr4 = tr4 * tr4
|
||||
taTr = tr4 - (tr4 - ta4) / emissivity
|
||||
|
||||
ktaScale = math.pow(2, self.ktaScale)
|
||||
kvScale = math.pow(2, self.kvScale)
|
||||
alphaScale = math.pow(2, self.alphaScale)
|
||||
|
||||
alphaCorrR[0] = 1 / (1 + self.ksTo[0] * 40)
|
||||
alphaCorrR[1] = 1
|
||||
alphaCorrR[2] = 1 + self.ksTo[1] * self.ct[2]
|
||||
alphaCorrR[3] = alphaCorrR[2] * (1 + self.ksTo[2] * (self.ct[3] - self.ct[2]))
|
||||
|
||||
# --------- Gain calculation -----------------------------------
|
||||
gain = frameData[778]
|
||||
if gain > 32767:
|
||||
gain -= 65536
|
||||
gain = self.gainEE / gain
|
||||
|
||||
# --------- To calculation -------------------------------------
|
||||
mode = (frameData[832] & 0x1000) >> 5
|
||||
|
||||
irDataCP[0] = frameData[776]
|
||||
irDataCP[1] = frameData[808]
|
||||
for i in range(2):
|
||||
if irDataCP[i] > 32767:
|
||||
irDataCP[i] -= 65536
|
||||
irDataCP[i] *= gain
|
||||
|
||||
irDataCP[0] -= (
|
||||
self.cpOffset[0]
|
||||
* (1 + self.cpKta * (ta - 25))
|
||||
* (1 + self.cpKv * (vdd - 3.3))
|
||||
)
|
||||
if mode == self.calibrationModeEE:
|
||||
irDataCP[1] -= (
|
||||
self.cpOffset[1]
|
||||
* (1 + self.cpKta * (ta - 25))
|
||||
* (1 + self.cpKv * (vdd - 3.3))
|
||||
)
|
||||
else:
|
||||
irDataCP[1] -= (
|
||||
(self.cpOffset[1] + self.ilChessC[0])
|
||||
* (1 + self.cpKta * (ta - 25))
|
||||
* (1 + self.cpKv * (vdd - 3.3))
|
||||
)
|
||||
|
||||
for pixelNumber in range(768):
|
||||
ilPattern = pixelNumber // 32 - (pixelNumber // 64) * 2
|
||||
chessPattern = ilPattern ^ (pixelNumber - (pixelNumber // 2) * 2)
|
||||
conversionPattern = (
|
||||
(pixelNumber + 2) // 4
|
||||
- (pixelNumber + 3) // 4
|
||||
+ (pixelNumber + 1) // 4
|
||||
- pixelNumber // 4
|
||||
) * (1 - 2 * ilPattern)
|
||||
|
||||
if mode == 0:
|
||||
pattern = ilPattern
|
||||
else:
|
||||
pattern = chessPattern
|
||||
|
||||
if pattern == frameData[833]:
|
||||
irData = frameData[pixelNumber]
|
||||
if irData > 32767:
|
||||
irData -= 65536
|
||||
irData *= gain
|
||||
|
||||
kta = self.kta[pixelNumber] / ktaScale
|
||||
kv = self.kv[pixelNumber] / kvScale
|
||||
irData -= (
|
||||
self.offset[pixelNumber]
|
||||
* (1 + kta * (ta - 25))
|
||||
* (1 + kv * (vdd - 3.3))
|
||||
)
|
||||
|
||||
if mode != self.calibrationModeEE:
|
||||
irData += (
|
||||
self.ilChessC[2] * (2 * ilPattern - 1)
|
||||
- self.ilChessC[1] * conversionPattern
|
||||
)
|
||||
|
||||
irData = irData - self.tgc * irDataCP[subPage]
|
||||
irData /= emissivity
|
||||
|
||||
alphaCompensated = SCALEALPHA * alphaScale / self.alpha[pixelNumber]
|
||||
alphaCompensated *= 1 + self.KsTa * (ta - 25)
|
||||
|
||||
Sx = (
|
||||
alphaCompensated
|
||||
* alphaCompensated
|
||||
* alphaCompensated
|
||||
* (irData + alphaCompensated * taTr)
|
||||
)
|
||||
Sx = math.sqrt(math.sqrt(Sx)) * self.ksTo[1]
|
||||
|
||||
To = (
|
||||
math.sqrt(
|
||||
math.sqrt(
|
||||
irData
|
||||
/ (alphaCompensated * (1 - self.ksTo[1] * 273.15) + Sx)
|
||||
+ taTr
|
||||
)
|
||||
)
|
||||
- 273.15
|
||||
)
|
||||
|
||||
if To < self.ct[1]:
|
||||
torange = 0
|
||||
elif To < self.ct[2]:
|
||||
torange = 1
|
||||
elif To < self.ct[3]:
|
||||
torange = 2
|
||||
else:
|
||||
torange = 3
|
||||
|
||||
To = (
|
||||
math.sqrt(
|
||||
math.sqrt(
|
||||
irData
|
||||
/ (
|
||||
alphaCompensated
|
||||
* alphaCorrR[torange]
|
||||
* (1 + self.ksTo[torange] * (To - self.ct[torange]))
|
||||
)
|
||||
+ taTr
|
||||
)
|
||||
)
|
||||
- 273.15
|
||||
)
|
||||
|
||||
result[pixelNumber] = To
|
||||
|
||||
# pylint: enable=too-many-locals, too-many-branches, too-many-statements
|
||||
|
||||
def _ExtractParameters(self):
|
||||
self._ExtractVDDParameters()
|
||||
self._ExtractPTATParameters()
|
||||
self._ExtractGainParameters()
|
||||
self._ExtractTgcParameters()
|
||||
self._ExtractResolutionParameters()
|
||||
self._ExtractKsTaParameters()
|
||||
self._ExtractKsToParameters()
|
||||
self._ExtractCPParameters()
|
||||
self._ExtractAlphaParameters()
|
||||
self._ExtractOffsetParameters()
|
||||
self._ExtractKtaPixelParameters()
|
||||
self._ExtractKvPixelParameters()
|
||||
self._ExtractCILCParameters()
|
||||
self._ExtractDeviatingPixels()
|
||||
|
||||
def _ExtractVDDParameters(self):
|
||||
# extract VDD
|
||||
self.kVdd = (eeData[51] & 0xFF00) >> 8
|
||||
if self.kVdd > 127:
|
||||
self.kVdd -= 256 # convert to signed
|
||||
self.kVdd *= 32
|
||||
self.vdd25 = eeData[51] & 0x00FF
|
||||
self.vdd25 = ((self.vdd25 - 256) << 5) - 8192
|
||||
|
||||
def _ExtractPTATParameters(self):
|
||||
# extract PTAT
|
||||
self.KvPTAT = (eeData[50] & 0xFC00) >> 10
|
||||
if self.KvPTAT > 31:
|
||||
self.KvPTAT -= 64
|
||||
self.KvPTAT /= 4096
|
||||
self.KtPTAT = eeData[50] & 0x03FF
|
||||
if self.KtPTAT > 511:
|
||||
self.KtPTAT -= 1024
|
||||
self.KtPTAT /= 8
|
||||
self.vPTAT25 = eeData[49]
|
||||
self.alphaPTAT = (eeData[16] & 0xF000) / math.pow(2, 14) + 8
|
||||
|
||||
def _ExtractGainParameters(self):
|
||||
# extract Gain
|
||||
self.gainEE = eeData[48]
|
||||
if self.gainEE > 32767:
|
||||
self.gainEE -= 65536
|
||||
|
||||
def _ExtractTgcParameters(self):
|
||||
# extract Tgc
|
||||
#print(eeData[60])
|
||||
self.tgc = eeData[60] & 0x00FF
|
||||
#print(self.tgc)
|
||||
if self.tgc > 127:
|
||||
self.tgc -= 256
|
||||
self.tgc /= 32
|
||||
#print(self.tgc)
|
||||
|
||||
def _ExtractResolutionParameters(self):
|
||||
# extract resolution
|
||||
self.resolutionEE = (eeData[56] & 0x3000) >> 12
|
||||
|
||||
def _ExtractKsTaParameters(self):
|
||||
# extract KsTa
|
||||
self.KsTa = (eeData[60] & 0xFF00) >> 8
|
||||
if self.KsTa > 127:
|
||||
self.KsTa -= 256
|
||||
self.KsTa /= 8192
|
||||
|
||||
def _ExtractKsToParameters(self):
|
||||
# extract ksTo
|
||||
step = ((eeData[63] & 0x3000) >> 12) * 10
|
||||
self.ct[0] = -40
|
||||
self.ct[1] = 0
|
||||
self.ct[2] = (eeData[63] & 0x00F0) >> 4
|
||||
self.ct[3] = (eeData[63] & 0x0F00) >> 8
|
||||
self.ct[2] *= step
|
||||
self.ct[3] = self.ct[2] + self.ct[3] * step
|
||||
|
||||
KsToScale = (eeData[63] & 0x000F) + 8
|
||||
KsToScale = 1 << KsToScale
|
||||
|
||||
self.ksTo[0] = eeData[61] & 0x00FF
|
||||
self.ksTo[1] = (eeData[61] & 0xFF00) >> 8
|
||||
self.ksTo[2] = eeData[62] & 0x00FF
|
||||
self.ksTo[3] = (eeData[62] & 0xFF00) >> 8
|
||||
|
||||
for i in range(4):
|
||||
if self.ksTo[i] > 127:
|
||||
self.ksTo[i] -= 256
|
||||
self.ksTo[i] /= KsToScale
|
||||
self.ksTo[4] = -0.0002
|
||||
|
||||
def _ExtractCPParameters(self):
|
||||
# extract CP
|
||||
offsetSP = [0] * 2
|
||||
alphaSP = [0] * 2
|
||||
|
||||
alphaScale = ((eeData[32] & 0xF000) >> 12) + 27
|
||||
|
||||
offsetSP[0] = eeData[58] & 0x03FF
|
||||
if offsetSP[0] > 511:
|
||||
offsetSP[0] -= 1024
|
||||
|
||||
offsetSP[1] = (eeData[58] & 0xFC00) >> 10
|
||||
if offsetSP[1] > 31:
|
||||
offsetSP[1] -= 64
|
||||
offsetSP[1] += offsetSP[0]
|
||||
|
||||
alphaSP[0] = eeData[57] & 0x03FF
|
||||
if alphaSP[0] > 511:
|
||||
alphaSP[0] -= 1024
|
||||
alphaSP[0] /= math.pow(2, alphaScale)
|
||||
|
||||
alphaSP[1] = (eeData[57] & 0xFC00) >> 10
|
||||
if alphaSP[1] > 31:
|
||||
alphaSP[1] -= 64
|
||||
alphaSP[1] = (1 + alphaSP[1] / 128) * alphaSP[0]
|
||||
|
||||
cpKta = eeData[59] & 0x00FF
|
||||
if cpKta > 127:
|
||||
cpKta -= 256
|
||||
ktaScale1 = ((eeData[56] & 0x00F0) >> 4) + 8
|
||||
self.cpKta = cpKta / math.pow(2, ktaScale1)
|
||||
|
||||
cpKv = (eeData[59] & 0xFF00) >> 8
|
||||
if cpKv > 127:
|
||||
cpKv -= 256
|
||||
kvScale = (eeData[56] & 0x0F00) >> 8
|
||||
self.cpKv = cpKv / math.pow(2, kvScale)
|
||||
|
||||
self.cpAlpha[0] = alphaSP[0]
|
||||
self.cpAlpha[1] = alphaSP[1]
|
||||
self.cpOffset[0] = offsetSP[0]
|
||||
self.cpOffset[1] = offsetSP[1]
|
||||
#print(self.cpAlpha[0])
|
||||
#print(self.cpAlpha[1])
|
||||
|
||||
def _ExtractAlphaParameters(self):
|
||||
# extract alpha
|
||||
accRemScale = eeData[32] & 0x000F
|
||||
accColumnScale = (eeData[32] & 0x00F0) >> 4
|
||||
accRowScale = (eeData[32] & 0x0F00) >> 8
|
||||
alphaScale = ((eeData[32] & 0xF000) >> 12) + 30
|
||||
alphaRef = eeData[33]
|
||||
accRow = [0] * 24
|
||||
accColumn = [0] * 32
|
||||
alphaTemp = [0] * 768
|
||||
|
||||
for i in range(6):
|
||||
p = i * 4
|
||||
accRow[p + 0] = eeData[34 + i] & 0x000F
|
||||
accRow[p + 1] = (eeData[34 + i] & 0x00F0) >> 4
|
||||
accRow[p + 2] = (eeData[34 + i] & 0x0F00) >> 8
|
||||
accRow[p + 3] = (eeData[34 + i] & 0xF000) >> 12
|
||||
|
||||
for i in range(24):
|
||||
if accRow[i] > 7:
|
||||
accRow[i] -= 16
|
||||
|
||||
for i in range(8):
|
||||
p = i * 4
|
||||
accColumn[p + 0] = eeData[40 + i] & 0x000F
|
||||
accColumn[p + 1] = (eeData[40 + i] & 0x00F0) >> 4
|
||||
accColumn[p + 2] = (eeData[40 + i] & 0x0F00) >> 8
|
||||
accColumn[p + 3] = (eeData[40 + i] & 0xF000) >> 12
|
||||
|
||||
for i in range(32):
|
||||
if accColumn[i] > 7:
|
||||
accColumn[i] -= 16
|
||||
for i in range(24):
|
||||
for j in range(32):
|
||||
p = 32 * i + j
|
||||
alphaTemp[p] = (eeData[64 + p] & 0x03F0) >> 4
|
||||
if alphaTemp[p] > 31:
|
||||
alphaTemp[p] -= 64
|
||||
alphaTemp[p] *= 1 << accRemScale
|
||||
alphaTemp[p] += (
|
||||
alphaRef
|
||||
+ (accRow[i] << accRowScale)
|
||||
+ (accColumn[j] << accColumnScale)
|
||||
)
|
||||
alphaTemp[p] /= math.pow(2, alphaScale)
|
||||
alphaTemp[p] -= self.tgc * (self.cpAlpha[0] + self.cpAlpha[1]) / 2
|
||||
alphaTemp[p] = SCALEALPHA / alphaTemp[p]
|
||||
# print("alphaTemp: ", alphaTemp)
|
||||
|
||||
temp = max(alphaTemp)
|
||||
#print("temp", temp)
|
||||
|
||||
alphaScale = 0
|
||||
while temp < 32768:
|
||||
temp *= 2
|
||||
alphaScale += 1
|
||||
|
||||
for i in range(768):
|
||||
temp = alphaTemp[i] * math.pow(2, alphaScale)
|
||||
self.alpha[i] = int(temp + 0.5)
|
||||
|
||||
self.alphaScale = alphaScale
|
||||
|
||||
def _ExtractOffsetParameters(self):
|
||||
# extract offset
|
||||
occRow = [0] * 24
|
||||
occColumn = [0] * 32
|
||||
|
||||
occRemScale = eeData[16] & 0x000F
|
||||
occColumnScale = (eeData[16] & 0x00F0) >> 4
|
||||
occRowScale = (eeData[16] & 0x0F00) >> 8
|
||||
offsetRef = eeData[17]
|
||||
if offsetRef > 32767:
|
||||
offsetRef -= 65536
|
||||
|
||||
for i in range(6):
|
||||
p = i * 4
|
||||
occRow[p + 0] = eeData[18 + i] & 0x000F
|
||||
occRow[p + 1] = (eeData[18 + i] & 0x00F0) >> 4
|
||||
occRow[p + 2] = (eeData[18 + i] & 0x0F00) >> 8
|
||||
occRow[p + 3] = (eeData[18 + i] & 0xF000) >> 12
|
||||
|
||||
for i in range(24):
|
||||
if occRow[i] > 7:
|
||||
occRow[i] -= 16
|
||||
|
||||
for i in range(8):
|
||||
p = i * 4
|
||||
occColumn[p + 0] = eeData[24 + i] & 0x000F
|
||||
occColumn[p + 1] = (eeData[24 + i] & 0x00F0) >> 4
|
||||
occColumn[p + 2] = (eeData[24 + i] & 0x0F00) >> 8
|
||||
occColumn[p + 3] = (eeData[24 + i] & 0xF000) >> 12
|
||||
|
||||
for i in range(32):
|
||||
if occColumn[i] > 7:
|
||||
occColumn[i] -= 16
|
||||
|
||||
for i in range(24):
|
||||
for j in range(32):
|
||||
p = 32 * i + j
|
||||
self.offset[p] = (eeData[64 + p] & 0xFC00) >> 10
|
||||
if self.offset[p] > 31:
|
||||
self.offset[p] -= 64
|
||||
self.offset[p] *= 1 << occRemScale
|
||||
self.offset[p] += (
|
||||
offsetRef
|
||||
+ (occRow[i] << occRowScale)
|
||||
+ (occColumn[j] << occColumnScale)
|
||||
)
|
||||
|
||||
def _ExtractKtaPixelParameters(self): # pylint: disable=too-many-locals
|
||||
# extract KtaPixel
|
||||
KtaRC = [0] * 4
|
||||
ktaTemp = [0] * 768
|
||||
|
||||
KtaRoCo = (eeData[54] & 0xFF00) >> 8
|
||||
if KtaRoCo > 127:
|
||||
KtaRoCo -= 256
|
||||
KtaRC[0] = KtaRoCo
|
||||
|
||||
KtaReCo = eeData[54] & 0x00FF
|
||||
if KtaReCo > 127:
|
||||
KtaReCo -= 256
|
||||
KtaRC[2] = KtaReCo
|
||||
|
||||
KtaRoCe = (eeData[55] & 0xFF00) >> 8
|
||||
if KtaRoCe > 127:
|
||||
KtaRoCe -= 256
|
||||
KtaRC[1] = KtaRoCe
|
||||
|
||||
KtaReCe = eeData[55] & 0x00FF
|
||||
if KtaReCe > 127:
|
||||
KtaReCe -= 256
|
||||
KtaRC[3] = KtaReCe
|
||||
|
||||
ktaScale1 = ((eeData[56] & 0x00F0) >> 4) + 8
|
||||
ktaScale2 = eeData[56] & 0x000F
|
||||
|
||||
for i in range(24):
|
||||
for j in range(32):
|
||||
p = 32 * i + j
|
||||
split = 2 * (p // 32 - (p // 64) * 2) + p % 2
|
||||
ktaTemp[p] = (eeData[64 + p] & 0x000E) >> 1
|
||||
if ktaTemp[p] > 3:
|
||||
ktaTemp[p] -= 8
|
||||
ktaTemp[p] *= 1 << ktaScale2
|
||||
ktaTemp[p] += KtaRC[split]
|
||||
ktaTemp[p] /= math.pow(2, ktaScale1)
|
||||
# ktaTemp[p] = ktaTemp[p] * mlx90640->offset[p];
|
||||
|
||||
temp = abs(ktaTemp[0])
|
||||
for kta in ktaTemp:
|
||||
temp = max(temp, abs(kta))
|
||||
|
||||
ktaScale1 = 0
|
||||
while temp < 64:
|
||||
temp *= 2
|
||||
ktaScale1 += 1
|
||||
|
||||
for i in range(768):
|
||||
temp = ktaTemp[i] * math.pow(2, ktaScale1)
|
||||
if temp < 0:
|
||||
self.kta[i] = int(temp - 0.5)
|
||||
else:
|
||||
self.kta[i] = int(temp + 0.5)
|
||||
self.ktaScale = ktaScale1
|
||||
|
||||
def _ExtractKvPixelParameters(self):
|
||||
KvT = [0] * 4
|
||||
kvTemp = [0] * 768
|
||||
|
||||
KvRoCo = (eeData[52] & 0xF000) >> 12
|
||||
if KvRoCo > 7:
|
||||
KvRoCo -= 16
|
||||
KvT[0] = KvRoCo
|
||||
|
||||
KvReCo = (eeData[52] & 0x0F00) >> 8
|
||||
if KvReCo > 7:
|
||||
KvReCo -= 16
|
||||
KvT[2] = KvReCo
|
||||
|
||||
KvRoCe = (eeData[52] & 0x00F0) >> 4
|
||||
if KvRoCe > 7:
|
||||
KvRoCe -= 16
|
||||
KvT[1] = KvRoCe
|
||||
|
||||
KvReCe = eeData[52] & 0x000F
|
||||
if KvReCe > 7:
|
||||
KvReCe -= 16
|
||||
KvT[3] = KvReCe
|
||||
|
||||
kvScale = (eeData[56] & 0x0F00) >> 8
|
||||
|
||||
for i in range(24):
|
||||
for j in range(32):
|
||||
p = 32 * i + j
|
||||
split = 2 * (p // 32 - (p // 64) * 2) + p % 2
|
||||
kvTemp[p] = KvT[split]
|
||||
kvTemp[p] /= math.pow(2, kvScale)
|
||||
# kvTemp[p] = kvTemp[p] * mlx90640->offset[p];
|
||||
|
||||
temp = abs(kvTemp[0])
|
||||
for kv in kvTemp:
|
||||
temp = max(temp, abs(kv))
|
||||
|
||||
kvScale = 0
|
||||
while temp < 64:
|
||||
temp *= 2
|
||||
kvScale += 1
|
||||
|
||||
for i in range(768):
|
||||
temp = kvTemp[i] * math.pow(2, kvScale)
|
||||
if temp < 0:
|
||||
self.kv[i] = int(temp - 0.5)
|
||||
else:
|
||||
self.kv[i] = int(temp + 0.5)
|
||||
self.kvScale = kvScale
|
||||
|
||||
def _ExtractCILCParameters(self):
|
||||
ilChessC = [0] * 3
|
||||
|
||||
self.calibrationModeEE = (eeData[10] & 0x0800) >> 4
|
||||
self.calibrationModeEE = self.calibrationModeEE ^ 0x80
|
||||
|
||||
ilChessC[0] = eeData[53] & 0x003F
|
||||
if ilChessC[0] > 31:
|
||||
ilChessC[0] -= 64
|
||||
ilChessC[0] /= 16.0
|
||||
|
||||
ilChessC[1] = (eeData[53] & 0x07C0) >> 6
|
||||
if ilChessC[1] > 15:
|
||||
ilChessC[1] -= 32
|
||||
ilChessC[1] /= 2.0
|
||||
|
||||
ilChessC[2] = (eeData[53] & 0xF800) >> 11
|
||||
if ilChessC[2] > 15:
|
||||
ilChessC[2] -= 32
|
||||
ilChessC[2] /= 8.0
|
||||
|
||||
self.ilChessC = ilChessC
|
||||
|
||||
def _ExtractDeviatingPixels(self):
|
||||
self.brokenPixels = [0xFFFF] * 5
|
||||
self.outlierPixels = [0xFFFF] * 5
|
||||
|
||||
pixCnt = 0
|
||||
brokenPixCnt = 0
|
||||
outlierPixCnt = 0
|
||||
|
||||
while (pixCnt < 768) and (brokenPixCnt < 5) and (outlierPixCnt < 5):
|
||||
if eeData[pixCnt + 64] == 0:
|
||||
self.brokenPixels[brokenPixCnt] = pixCnt
|
||||
brokenPixCnt += 1
|
||||
elif (eeData[pixCnt + 64] & 0x0001) != 0:
|
||||
self.outlierPixels[outlierPixCnt] = pixCnt
|
||||
outlierPixCnt += 1
|
||||
pixCnt += 1
|
||||
|
||||
if brokenPixCnt > 4:
|
||||
raise RuntimeError("More than 4 broken pixels")
|
||||
if outlierPixCnt > 4:
|
||||
raise RuntimeError("More than 4 outlier pixels")
|
||||
if (brokenPixCnt + outlierPixCnt) > 4:
|
||||
raise RuntimeError("More than 4 faulty pixels")
|
||||
# print("Found %d broken pixels, %d outliers" % (brokenPixCnt, outlierPixCnt))
|
||||
# TODO INCOMPLETE
|
||||
|
||||
def _I2CWriteWord(self, writeAddress, data):
|
||||
cmd = bytearray(4)
|
||||
cmd[0] = writeAddress >> 8
|
||||
cmd[1] = writeAddress & 0x00FF
|
||||
cmd[2] = data >> 8
|
||||
cmd[3] = data & 0x00FF
|
||||
dataCheck = [0]
|
||||
|
||||
with self.i2c_device as i2c:
|
||||
i2c.write(cmd)
|
||||
# print("Wrote:", [hex(i) for i in cmd])
|
||||
time.sleep(0.001)
|
||||
self._I2CReadWords(writeAddress, dataCheck)
|
||||
# print("dataCheck: 0x%x" % dataCheck[0])
|
||||
# if (dataCheck != data):
|
||||
# return -2
|
||||
|
||||
_inbuf = bytearray(2 * I2C_READ_LEN)
|
||||
|
||||
def _I2CReadWords(self, addr, buffer, *, end=None):
|
||||
# stamp = time.monotonic()
|
||||
if end is None:
|
||||
remainingWords = len(buffer)
|
||||
else:
|
||||
remainingWords = end
|
||||
offset = 0
|
||||
addrbuf = bytearray(2)
|
||||
# inbuf = bytearray(2 * I2C_READ_LEN)
|
||||
inbuf = self._inbuf
|
||||
|
||||
with self.i2c_device as i2c:
|
||||
while remainingWords:
|
||||
addrbuf[0] = addr >> 8 # MSB
|
||||
addrbuf[1] = addr & 0xFF # LSB
|
||||
read_words = min(remainingWords, I2C_READ_LEN)
|
||||
i2c.write_then_readinto(
|
||||
addrbuf, inbuf, in_end=read_words * 2
|
||||
) # in bytes
|
||||
# print("-> ", [hex(i) for i in addrbuf])
|
||||
|
||||
outwords = struct.unpack(
|
||||
">" + "H" * read_words, inbuf[0 : read_words * 2]
|
||||
)
|
||||
# print("<- (", read_words, ")", [hex(i) for i in outwords])
|
||||
for i, w in enumerate(outwords):
|
||||
buffer[offset + i] = w
|
||||
offset += read_words
|
||||
remainingWords -= read_words
|
||||
addr += read_words
|
||||
|
||||
ixc = None
|
||||
mlx = None
|
||||
frame = None
|
||||
|
||||
def init_camera(scl_pin=22, sda_pin=21, freq=100000):
|
||||
"""Explicitly initializes the I2C bus and camera after power is stable."""
|
||||
global ixc, mlx, frame
|
||||
|
||||
print(f"[Camera] Initializing I2C on SCL:{scl_pin}, SDA:{sda_pin} at {freq}Hz...")
|
||||
ixc = I2C(pins=(scl_pin, sda_pin), frequency=freq)
|
||||
|
||||
print("[Camera] Probing for MLX90640...")
|
||||
mlx = MLX90640(ixc)
|
||||
|
||||
# Bonus: Your wiki snapshot recommends 16Hz for smooth images!
|
||||
mlx.refresh_rate = RefreshRate.REFRESH_16_HZ
|
||||
|
||||
frame = [0] * 768
|
||||
print("[Camera] Setup successful!")
|
||||
|
||||
def read_temperature():
|
||||
if mlx is None:
|
||||
print("[Camera] Error: Camera not initialized. Call init_camera() first.")
|
||||
return None
|
||||
try:
|
||||
print("Querying camera...")
|
||||
mlx.getFrame(frame)
|
||||
return frame
|
||||
except Exception as e:
|
||||
print(f"[Camera] Read error: {e}")
|
||||
return None
|
||||
@@ -0,0 +1,62 @@
|
||||
import ustruct
|
||||
|
||||
class SensorBase:
|
||||
|
||||
def read16(self, register):
|
||||
data = self.i2c.readfrom_mem(self.address, register, 2)
|
||||
return ustruct.unpack('<H', data)[0]
|
||||
|
||||
def read_temp(self, register):
|
||||
temp = self.read16(register);
|
||||
# apply measurement resolution (0.02 degrees per LSB)
|
||||
temp *= .02;
|
||||
# Kelvin to Celcius
|
||||
temp -= 273.15;
|
||||
return temp;
|
||||
|
||||
def read_ambient_temp(self):
|
||||
return self.read_temp(self._REGISTER_TA)
|
||||
|
||||
def read_object_temp(self):
|
||||
return self.read_temp(self._REGISTER_TOBJ1)
|
||||
|
||||
def read_object2_temp(self):
|
||||
if self.dual_zone:
|
||||
return self.read_temp(self._REGISTER_TOBJ2)
|
||||
else:
|
||||
raise RuntimeError("Device only has one thermopile")
|
||||
|
||||
@property
|
||||
def ambient_temp(self):
|
||||
return self.read_ambient_temp()
|
||||
|
||||
@property
|
||||
def object_temp(self):
|
||||
return self.read_object_temp()
|
||||
|
||||
@property
|
||||
def object2_temp(self):
|
||||
return self.read_object2_temp()
|
||||
|
||||
class MLX90614(SensorBase):
|
||||
|
||||
_REGISTER_TA = 0x06
|
||||
_REGISTER_TOBJ1 = 0x07
|
||||
_REGISTER_TOBJ2 = 0x08
|
||||
|
||||
def __init__(self, i2c, address=0x5a):
|
||||
self.i2c = i2c
|
||||
self.address = address
|
||||
_config1 = i2c.readfrom_mem(address, 0x25, 2)
|
||||
_dz = ustruct.unpack('<H', _config1)[0] & (1<<6)
|
||||
self.dual_zone = True if _dz else False
|
||||
|
||||
class MLX90615(SensorBase):
|
||||
|
||||
_REGISTER_TA = 0x26
|
||||
_REGISTER_TOBJ1 = 0x27
|
||||
|
||||
def __init__(self, i2c, address=0x5b):
|
||||
self.i2c = i2c
|
||||
self.address = address
|
||||
self.dual_zone = False
|
||||
@@ -0,0 +1 @@
|
||||
db.sqlite*
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+311
-145
@@ -1,53 +1,53 @@
|
||||
import base64
|
||||
import json
|
||||
import threading
|
||||
import queue
|
||||
import time
|
||||
import traceback
|
||||
import asyncio
|
||||
import requests
|
||||
|
||||
from orchestrateur.sensors import gps
|
||||
from shared import get_lora, get_mqtt_client, deviceTypes, config, payloads
|
||||
from shared.logging import log
|
||||
from shared.cookingState import CookingStates
|
||||
from shared.lora_device import LoraCommands
|
||||
from sensors import ultrasonicRanger, temp_hum, button, camera
|
||||
|
||||
# --- Read Unique Device ID ---
|
||||
try:
|
||||
with open("device_id.txt", "r") as f:
|
||||
DEVICE_ID = f.read().strip()
|
||||
except Exception:
|
||||
try:
|
||||
with open("/home/pi/SmartWave/orchestrateur/device_id.txt", "r") as f:
|
||||
DEVICE_ID = f.read().strip()
|
||||
except Exception:
|
||||
DEVICE_ID = "RPI_Orchestrateur_Default"
|
||||
def get_device_id():
|
||||
for path in ["device_id.txt", "/home/pi/SmartWave/orchestrateur/device_id.txt"]:
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
return f.read().strip()
|
||||
except Exception:
|
||||
pass
|
||||
return "RPI_Orchestrateur_Default"
|
||||
|
||||
# Thread-safe queue for application messages
|
||||
data_queue = queue.Queue()
|
||||
DEVICE_ID = get_device_id()
|
||||
|
||||
# --- STATE MACHINE DEFINITIONS ---
|
||||
class MicrowaveState:
|
||||
IDLE = "IDLE" # Microwave is empty
|
||||
ANALYZING = "ANALYZING" # Reading sensors & waiting for IR
|
||||
WAITING_FOR_CLOUD = "WAITING_FOR_CLOUD" # Waiting for API parameters
|
||||
COOKING = "COOKING" # Microwave is active
|
||||
DONE = "DONE" # Finished/Stopped, waiting for dish removal
|
||||
|
||||
# Global state trackers
|
||||
microwave_states = {"2": MicrowaveState.IDLE}
|
||||
button_state = False
|
||||
async_event_queue = None
|
||||
# Async synchronization trackers for MQTT IR sensors responses
|
||||
ir_data_cache = {} # mw_id -> dict of IR readings
|
||||
ir_data_events = {} # mw_id -> asyncio.Event()
|
||||
|
||||
|
||||
# --- HARDWARE SETUP ---
|
||||
lora = get_lora()
|
||||
lora.configure()
|
||||
|
||||
def lora_listener():
|
||||
"""Background Thread: Listens to LoRa traffic and responds to Heartbeats."""
|
||||
print("Thread Écouteur LoRa démarré.")
|
||||
while True:
|
||||
paquet = lora.receive_packet(timeout_ms=1000)
|
||||
if paquet:
|
||||
donnees = paquet["data"]
|
||||
expediteur_type = donnees.get("type")
|
||||
|
||||
if expediteur_type == deviceTypes.DEVICE_TYPES["MICROWAVE"]:
|
||||
print(f"\n[Thread LoRa] Heartbeat reçu de {donnees.get('id')}")
|
||||
reponse = {
|
||||
"id": DEVICE_ID,
|
||||
"type": deviceTypes.DEVICE_TYPES["ORCHESTRATOR"]
|
||||
}
|
||||
lora.send(reponse)
|
||||
else:
|
||||
data_queue.put({"source": "LoRa", "data": paquet})
|
||||
|
||||
# --- Setup & Connect MQTT ---
|
||||
mqtt_client = get_mqtt_client(
|
||||
host="192.168.50.1", # Using explicit gateway IP to dodge Docker loopback blocks
|
||||
client_id="smartwave-orchestrateur-"+DEVICE_ID,
|
||||
host="192.168.50.1",
|
||||
client_id="smartwave-orchestrateur-" + DEVICE_ID,
|
||||
use_tls=config.USE_TLS,
|
||||
cafile="/home/pi/SmartWave/orchestrateur/mqtt/certs/ca.crt",
|
||||
keepalive=config.MQTT_KEEPALIVE,
|
||||
@@ -55,139 +55,305 @@ mqtt_client = get_mqtt_client(
|
||||
mqtt_client.connect()
|
||||
mqtt_client.subscribe(config.MQTT_TOPIC_SENSOR, qos=config.MQTT_QOS)
|
||||
mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS)
|
||||
print(f"Subscribed to topic: {config.MQTT_TOPIC_SENSOR}")
|
||||
|
||||
# --- THE CRUCIAL PAHO FIX ---
|
||||
# Start Paho's internal background thread. This handles all network packets,
|
||||
# automatic keepalive pings, and delivery receipts cleanly.
|
||||
if hasattr(mqtt_client._client, "loop_start"):
|
||||
mqtt_client._client.loop_start()
|
||||
print("Paho MQTT asynchronous network loop started.")
|
||||
print("[MQTT] Paho background loop started.")
|
||||
|
||||
# --- BACKGROUND TASKS (PRODUCERS) ---
|
||||
async def lora_listener_task():
|
||||
"""Polls LoRa and pushes to the async queue."""
|
||||
print("[LoRa] Async listener started.")
|
||||
while True:
|
||||
# Run blocking lora receive in a thread to not block asyncio loop
|
||||
paquet = await asyncio.to_thread(lora.receive_reliable, timeout_ms=100)
|
||||
if paquet:
|
||||
await async_event_queue.put({"source": "LoRa", "data": paquet})
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
def mqtt_listener():
|
||||
"""Background Thread: Constantly inspects incoming MQTT message cache."""
|
||||
print("Thread MQTT démarré.")
|
||||
async def mqtt_listener_task():
|
||||
"""Polls MQTT cache and pushes to the async queue."""
|
||||
print("[MQTT] Async listener started.")
|
||||
while True:
|
||||
message = mqtt_client.get_message()
|
||||
|
||||
if message:
|
||||
# Try to parse the payload as a python dictionary, but if it fails, just print the raw payload
|
||||
try:
|
||||
payload = json.loads(message['payload'])
|
||||
except Exception as e:
|
||||
print(f"Error parsing MQTT payload: {e}")
|
||||
payload = message['payload'] # Fallback to raw payload if parsing fails
|
||||
except Exception:
|
||||
payload = message['payload']
|
||||
|
||||
print(f"\n[Thread MQTT] Message reçu : {message}")
|
||||
data_queue.put({"source": "MQTT", "topic": message['topic'] ,"data": payload})
|
||||
|
||||
# Sleep for 100ms. Prevents the thread from turning into an infinite 100% CPU hog.
|
||||
time.sleep(0.2)
|
||||
# --- SAFE TOPIC DECODING ---
|
||||
topic = message['topic']
|
||||
if isinstance(topic, bytes):
|
||||
topic = topic.decode('utf-8')
|
||||
|
||||
await async_event_queue.put({
|
||||
"source": "MQTT",
|
||||
"topic": topic,
|
||||
"data": payload
|
||||
})
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Button
|
||||
button_state = False
|
||||
def button_callback():
|
||||
"""Button physical interrupt callback."""
|
||||
global button_state
|
||||
button_state = not button_state
|
||||
print(f"\n[Thread Button] Button state changed to: {button_state}")
|
||||
|
||||
button.set_callback(button_callback)
|
||||
if microwave_states.get("2") == MicrowaveState.COOKING:
|
||||
print("[Button] Toggling pause/resume for microwave '2'.")
|
||||
lora.send_reliable({"id": DEVICE_ID, "microwave_id": "2", "action": LoraCommands.TOGGLE_PAUSE})
|
||||
else:
|
||||
button_state = not button_state
|
||||
print(f"[Button] Defrost state toggled to: {button_state}")
|
||||
|
||||
# Launch background monitoring workers
|
||||
# threading.Thread(target=lora_listener, daemon=True).start()
|
||||
# threading.Thread(target=mqtt_listener, daemon=True).start()
|
||||
# Launch button monitoring thread
|
||||
button.set_callback(button_callback)
|
||||
button.start_button_monitoring_thread()
|
||||
|
||||
print("Orchestrateur prêt. Le main loop est libre.")
|
||||
# --- HARDWARE CONTROLLERS ---
|
||||
def _stop_hardware(microwave_id: str):
|
||||
print(f"[{microwave_id}] /!\ Emergency stop issued to hardware.")
|
||||
# TODO: Add LoRa STOP command here
|
||||
|
||||
# Sensor reading
|
||||
def read_sensors():
|
||||
"""Read all sensors and return a dictionary of their values."""
|
||||
log("\nLecture des capteurs...")
|
||||
sensor_data = {}
|
||||
# --- ASYNC COOKING LOGIC ---
|
||||
def read_local_sensors(microwave_id, initial_dish_height):
|
||||
"""Blocking function to read local I2C/SPI sensors. Runs in a thread."""
|
||||
print(f"[{microwave_id}] Reading local physical sensors...")
|
||||
sensor_data = {
|
||||
"microwave_id": microwave_id,
|
||||
"defrost_mode": button_state,
|
||||
"ultrasonic_distance": initial_dish_height # Reuse height from trigger
|
||||
}
|
||||
|
||||
# Read Ultrasonic Ranger
|
||||
distance = ultrasonicRanger.get_dish_height()
|
||||
if distance is not None:
|
||||
log(f"\nLecture du capteur Ultrason : {distance}")
|
||||
sensor_data["ultrasonic_distance"] = distance
|
||||
|
||||
# Read Temperature and Humidity
|
||||
temperature, humidity = temp_hum.get_temperature_and_humidity()
|
||||
if temperature is not None and humidity is not None:
|
||||
log(f"\nLecture du capteur Temp/Hum : {temperature}, {humidity}")
|
||||
sensor_data["temperature"] = temperature
|
||||
sensor_data["humidity"] = humidity
|
||||
|
||||
|
||||
# Read GPS Data
|
||||
gps_data = gps.get_gps_data()
|
||||
if gps_data:
|
||||
log(f"\nLecture du capteur GPS : {gps_data}")
|
||||
sensor_data["gps"] = gps_data
|
||||
# Temp / Hum (handles DHT error safely)
|
||||
try:
|
||||
temp, hum = temp_hum.get_temperature_and_humidity_with_retry()
|
||||
if temp is not None:
|
||||
sensor_data["temperature"] = temp
|
||||
sensor_data["humidity"] = hum
|
||||
except Exception as e:
|
||||
log(f"[{microwave_id}] DHT read warning: {e}")
|
||||
|
||||
# Camera
|
||||
picture_bytes = None
|
||||
try:
|
||||
picture_bytes = camera.get_picture()
|
||||
log(f"\nLecture du capteur Caméra : {len(picture_bytes)} bytes")
|
||||
sensor_data["camera_image"] = picture_bytes
|
||||
sensor_data["camera_image"] = camera.get_picture()
|
||||
except Exception as e:
|
||||
log(f"Error reading camera data: {e}")
|
||||
|
||||
# Read Button State (last because he can still change state while reading other sensors)
|
||||
sensor_data["button_state"] = button_state
|
||||
|
||||
log(f"[{microwave_id}] Camera read failed: {e}")
|
||||
|
||||
return sensor_data
|
||||
|
||||
# --- MAIN EXECUTION LOOP ---
|
||||
while True:
|
||||
try:
|
||||
# Check for non-heartbeat data
|
||||
try:
|
||||
msg = data_queue.get(block=False)
|
||||
|
||||
# print(msg)
|
||||
|
||||
if msg["source"] == "LoRa":
|
||||
print(f"\n[Main Loop] LoRa : Données traitées : {msg['data']}")
|
||||
elif msg["source"] == "MQTT":
|
||||
if (msg["topic"] == config.MQTT_TOPIC_HELLO.decode('utf-8')):
|
||||
if ("id_orchestrator" in msg["data"] and msg["data"]["id_orchestrator"] == DEVICE_ID):
|
||||
# Do not answer to messages coming from me
|
||||
continue
|
||||
microwave_id = msg["data"]["id_microwave"]
|
||||
print(f"\n[Main Loop] MQTT : Hello reçu de {microwave_id}.")
|
||||
# Responds
|
||||
mqtt_client.publish(config.MQTT_TOPIC_HELLO, payloads.mqtt_hello_ack(DEVICE_ID, microwave_id), qos=config.MQTT_QOS)
|
||||
print(f"[Main Loop] MQTT : Réponse Hello envoyée à {microwave_id}.")
|
||||
# TODO : Save in database
|
||||
|
||||
|
||||
print(f"\n[Main Loop] MQTT : Données traitées : {msg['data']}")
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
# DEBUG : Read sensors
|
||||
sensor_values = read_sensors()
|
||||
if sensor_values:
|
||||
sensor_values_print = sensor_values.copy()
|
||||
if "camera_image" in sensor_values_print:
|
||||
sensor_values_print["camera_image"] = f"<{len(sensor_values_print['camera_image'])} bytes>"
|
||||
print(f"\nCapteurs Données lues : {sensor_values_print}")
|
||||
|
||||
time.sleep(3)
|
||||
|
||||
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
time.sleep(1) # Prevents rapid error logging in case of persistent issues
|
||||
|
||||
# Clean termination
|
||||
if hasattr(mqtt_client._client, "loop_stop"):
|
||||
mqtt_client._client.loop_stop()
|
||||
mqtt_client.close()
|
||||
async def handle_new_dish(microwave_id, detected_height):
|
||||
"""Triggered when a new dish is placed inside."""
|
||||
microwave_states[microwave_id] = MicrowaveState.ANALYZING
|
||||
print(f"\n[{microwave_id}] 🍽️ Dish detected at {detected_height:.1f} cm! Requesting IR from microwave...")
|
||||
|
||||
# 1. Setup synchronization event and clear previous cache for this microwave
|
||||
event = asyncio.Event()
|
||||
ir_data_events[microwave_id] = event
|
||||
ir_data_cache.pop(microwave_id, None)
|
||||
|
||||
# 2. Send IR request to ESP32 via MQTT immediately
|
||||
mqtt_client.publish(
|
||||
config.MQTT_TOPIC_COOKING,
|
||||
payloads.mqtt_cooking_init(microwave_id),
|
||||
qos=config.MQTT_QOS
|
||||
)
|
||||
|
||||
# 3. Start local sensor reading in parallel
|
||||
sensor_task = asyncio.create_task(asyncio.to_thread(read_local_sensors, microwave_id, detected_height))
|
||||
|
||||
# 4. Wait for local sensors to finish reading
|
||||
sensors_data = await sensor_task
|
||||
|
||||
# Check if dish was removed while reading sensors
|
||||
if microwave_states.get(microwave_id) != MicrowaveState.ANALYZING:
|
||||
print(f"[{microwave_id}] Dish removed during sensor read. Aborting.")
|
||||
ir_data_events.pop(microwave_id, None)
|
||||
return
|
||||
|
||||
# 5. Wait for MQTT IR data (if it already arrived, event.wait() returns instantly)
|
||||
try:
|
||||
await asyncio.wait_for(event.wait(), timeout=10.0)
|
||||
ir_payload = ir_data_cache.get(microwave_id, {})
|
||||
sensors_data["ir_initial_temp"] = ir_payload.get("dish_temp")
|
||||
sensors_data["ir_ambient_temp"] = ir_payload.get("ambient_temp")
|
||||
print(f"[{microwave_id}] IR data synchronized successfully: {ir_payload}")
|
||||
except asyncio.TimeoutError:
|
||||
print(f"[{microwave_id}] ⚠️ Timeout waiting for MQTT IR data from ESP32.")
|
||||
sensors_data["ir_initial_temp"] = None
|
||||
sensors_data["ir_ambient_temp"] = None
|
||||
finally:
|
||||
ir_data_events.pop(microwave_id, None)
|
||||
|
||||
# 6. Dispatch cloud request task
|
||||
asyncio.create_task(request_cloud_cooking_plan(microwave_id, sensors_data))
|
||||
|
||||
async def request_cloud_cooking_plan(microwave_id, sensors_data):
|
||||
"""Sends all data to the cloud and starts the microwave if successful."""
|
||||
microwave_states[microwave_id] = MicrowaveState.WAITING_FOR_CLOUD
|
||||
URL = "https://smartwave.matthiasg.dev/cooking-params"
|
||||
|
||||
# Format image
|
||||
if isinstance(sensors_data.get("camera_image"), bytes):
|
||||
sensors_data["camera_image"] = base64.b64encode(sensors_data["camera_image"]).decode("utf-8")
|
||||
|
||||
print(f"[{microwave_id}] Requesting cooking plan from cloud app...")
|
||||
try:
|
||||
response = await asyncio.to_thread(requests.post, URL, json=sensors_data, timeout=30)
|
||||
|
||||
# Abort if state changed (e.g. user removed dish while waiting for wifi)
|
||||
if microwave_states[microwave_id] != MicrowaveState.WAITING_FOR_CLOUD:
|
||||
print(f"[{microwave_id}] Dish removed during API request. Discarding API plan.")
|
||||
return
|
||||
|
||||
response.raise_for_status()
|
||||
plan = response.json().get("cook_plan", {})
|
||||
c_time = plan.get("cook_time_seconds")
|
||||
c_power = plan.get("effective_power_watts")
|
||||
c_temp = plan.get("target_temp")
|
||||
|
||||
if c_time is None or c_power is None or c_temp is None:
|
||||
print(f"[{microwave_id}] ❌ Invalid plan received: {response.json()}")
|
||||
microwave_states[microwave_id] = MicrowaveState.DONE # Fail safe
|
||||
return
|
||||
|
||||
print(f"[{microwave_id}] Cloud Plan Received! Starting microwave: {c_time}s @ {c_power}W")
|
||||
microwave_states[microwave_id] = MicrowaveState.COOKING
|
||||
mqtt_client.publish(
|
||||
config.MQTT_TOPIC_COOKING,
|
||||
payloads.mqtt_cooking_config(microwave_id, c_time, c_power, c_temp),
|
||||
qos=config.MQTT_QOS
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[{microwave_id}] Cloud API Error: {e}")
|
||||
microwave_states[microwave_id] = MicrowaveState.DONE
|
||||
|
||||
# --- MAIN LOGIC TASKS ---
|
||||
async def process_messages_task():
|
||||
"""Consumes the unified event queue."""
|
||||
while True:
|
||||
msg = await async_event_queue.get()
|
||||
source = msg["source"]
|
||||
data = msg["data"]
|
||||
|
||||
if source == "LoRa":
|
||||
if "new_cooking_state" in data.get("data", {}):
|
||||
mw_id = data["data"].get("id")
|
||||
n_state = data["data"].get("new_cooking_state")
|
||||
print(f"[LoRa] Microwave {mw_id} state changed to: {n_state}")
|
||||
|
||||
if n_state == CookingStates.IDLE and microwave_states.get(mw_id) == MicrowaveState.COOKING:
|
||||
microwave_states[mw_id] = MicrowaveState.DONE
|
||||
print(f"[{mw_id}] Cooking finished. Waiting for user to remove dish.")
|
||||
|
||||
elif source == "MQTT":
|
||||
topic = msg["topic"]
|
||||
|
||||
# Helper to normalize config topics to str
|
||||
def to_str(val):
|
||||
return val.decode('utf-8') if isinstance(val, bytes) else val
|
||||
|
||||
hello_topic = to_str(config.MQTT_TOPIC_HELLO)
|
||||
sensor_topic = to_str(config.MQTT_TOPIC_SENSOR)
|
||||
|
||||
if topic == hello_topic:
|
||||
if data.get("id_orchestrator") != DEVICE_ID:
|
||||
mw_id = data.get("id_microwave")
|
||||
print(f"[MQTT] Hello from {mw_id}. Sending ACK.")
|
||||
mqtt_client.publish(
|
||||
config.MQTT_TOPIC_HELLO,
|
||||
payloads.mqtt_hello_ack(DEVICE_ID, mw_id),
|
||||
qos=config.MQTT_QOS
|
||||
)
|
||||
|
||||
elif topic == sensor_topic:
|
||||
mw_id = str(data.get("id_microwave"))
|
||||
print(f"[MQTT] Sensor data received for microwave {mw_id}: {data}")
|
||||
|
||||
# Store IR data and notify the waiting dish handler
|
||||
ir_data_cache[mw_id] = data
|
||||
if mw_id in ir_data_events:
|
||||
ir_data_events[mw_id].set()
|
||||
|
||||
async def get_filtered_dish_height(samples=3, delay=0.04):
|
||||
"""Reads ultrasonic sensor multiple times and returns the median, discarding invalid zeros."""
|
||||
valid_samples = []
|
||||
for _ in range(samples):
|
||||
h = await asyncio.to_thread(ultrasonicRanger.get_dish_height)
|
||||
# Discard 0.0 or near-zero timeout glitches
|
||||
if h is not None and h > 0.5:
|
||||
valid_samples.append(h)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
if valid_samples:
|
||||
valid_samples.sort()
|
||||
return valid_samples[len(valid_samples) // 2] # Median sample
|
||||
return None # All reads failed or out of range
|
||||
|
||||
|
||||
async def monitor_dish_height_task():
|
||||
"""Monitors presence of dish with hysteresis and debouncing."""
|
||||
mw_id = "2"
|
||||
consecutive_present = 0
|
||||
consecutive_absent = 0
|
||||
REQUIRED_STABLE_READS = 3 # Must see 3 stable states in a row (~1 second)
|
||||
|
||||
while True:
|
||||
dist = await get_filtered_dish_height()
|
||||
current_state = microwave_states.get(mw_id, MicrowaveState.IDLE)
|
||||
|
||||
if dist is not None:
|
||||
# Hysteresis Thresholds:
|
||||
# - Must be > 2.5 cm to detect dish insertion
|
||||
# - Must be < 1.2 cm to detect dish removal
|
||||
if dist > 2.5:
|
||||
consecutive_present += 1
|
||||
consecutive_absent = 0
|
||||
elif dist < 1.2:
|
||||
consecutive_absent += 1
|
||||
consecutive_present = 0
|
||||
else:
|
||||
# Dead-zone (1.2cm to 2.5cm) -> Noise buffer
|
||||
consecutive_present = 0
|
||||
consecutive_absent = 0
|
||||
|
||||
# --- DISH INSERTED CONFIRMED ---
|
||||
if consecutive_present >= REQUIRED_STABLE_READS and current_state == MicrowaveState.IDLE:
|
||||
consecutive_present = 0
|
||||
asyncio.create_task(handle_new_dish(mw_id, dist))
|
||||
|
||||
# --- DISH REMOVED CONFIRMED ---
|
||||
elif consecutive_absent >= REQUIRED_STABLE_READS and current_state != MicrowaveState.IDLE:
|
||||
consecutive_absent = 0
|
||||
print(f"\n[{mw_id}] Dish Removed! Resetting state to IDLE.")
|
||||
microwave_states[mw_id] = MicrowaveState.IDLE
|
||||
if current_state == MicrowaveState.COOKING:
|
||||
_stop_hardware(mw_id)
|
||||
# Remove from IR cache and events
|
||||
ir_data_cache.pop(mw_id, None)
|
||||
ir_data_events.pop(mw_id, None)
|
||||
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
# --- BOOTSTRAP ---
|
||||
async def main():
|
||||
global async_event_queue
|
||||
print("🚀 Orchestrateur Asyncio prêt. Lancement des tâches...")
|
||||
|
||||
async_event_queue = asyncio.Queue()
|
||||
|
||||
await asyncio.gather(
|
||||
lora_listener_task(),
|
||||
mqtt_listener_task(),
|
||||
process_messages_task(),
|
||||
monitor_dish_height_task()
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
print("\nArrêt manuel.")
|
||||
finally:
|
||||
if hasattr(mqtt_client._client, "loop_stop"):
|
||||
mqtt_client._client.loop_stop()
|
||||
mqtt_client.close()
|
||||
@@ -11,7 +11,8 @@ grovepi.pinMode(button, "INPUT")
|
||||
button_callback = None
|
||||
|
||||
def read_button_state():
|
||||
if not grove_lock.acquire(timeout=0.05):
|
||||
# Increase timeout slightly so the button thread can wait for long I2C sensor reads to finish
|
||||
if not grove_lock.acquire(timeout=0.2):
|
||||
return None
|
||||
try:
|
||||
return grovepi.digitalRead(button)
|
||||
@@ -26,15 +27,18 @@ def monitor_button():
|
||||
last_button_state = button_switch_state
|
||||
|
||||
while True:
|
||||
time.sleep(0.04)
|
||||
|
||||
current_state = read_button_state()
|
||||
|
||||
if current_state is not None:
|
||||
# Rising edge detection (0 -> 1 transition)
|
||||
if current_state == 1 and last_button_state == 0:
|
||||
if button_callback:
|
||||
button_callback()
|
||||
last_button_state = current_state
|
||||
time.sleep(0.02) # Fast 20ms poll when lock is clear
|
||||
else:
|
||||
# Lock was busy; retry quickly without updating last_button_state
|
||||
time.sleep(0.01)
|
||||
|
||||
def start_button_monitoring_thread():
|
||||
threading.Thread(target=monitor_button, daemon=True).start()
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
import time,sys
|
||||
import RPi.GPIO as GPIO
|
||||
import smbus
|
||||
from shared import config
|
||||
|
||||
debug = 0
|
||||
debug = config.DEBUG
|
||||
# use the bus that matches your raspi version
|
||||
rev = GPIO.RPI_REVISION
|
||||
if rev == 2 or rev == 3:
|
||||
|
||||
@@ -1,35 +1,6 @@
|
||||
# from sensors.lib import grove_i2c_temp_hum_mini
|
||||
|
||||
# t= grove_i2c_temp_hum_mini.th02()
|
||||
|
||||
# def get_temperature():
|
||||
# """Get the temperature in Celsius from the TH02 sensor."""
|
||||
# # try:
|
||||
# return t.getTemperature()
|
||||
# # except Exception as e:
|
||||
# # print(f"Error reading temperature: {e}")
|
||||
# # return None
|
||||
|
||||
# def get_humidity():
|
||||
# """Get the humidity in percentage from the TH02 sensor."""
|
||||
# # try:
|
||||
# return t.getHumidity()
|
||||
# # except Exception as e:
|
||||
# # print(f"Error reading humidity: {e}")
|
||||
# # return None
|
||||
|
||||
# import seeed_dht
|
||||
|
||||
# sensor = seeed_dht.DHT("11", 4) # DHT11 sensor on GPIO pin 4
|
||||
|
||||
# def get_humidity_and_temperature():
|
||||
# humi, temp = sensor.read()
|
||||
# return humi, temp
|
||||
|
||||
|
||||
# import sensors.lib.grovepi as grovepi
|
||||
import grovepi
|
||||
import math
|
||||
import time
|
||||
from sensors.lock import grove_lock
|
||||
|
||||
# Connect the Grove Temperature & Humidity Sensor Pro to digital port D3
|
||||
@@ -50,3 +21,11 @@ def get_temperature_and_humidity():
|
||||
else:
|
||||
print("Error reading from DHT sensor")
|
||||
return None, None
|
||||
|
||||
def get_temperature_and_humidity_with_retry(max_retries=3):
|
||||
for _ in range(max_retries): # Try up to max_retries times
|
||||
temp, humidity = get_temperature_and_humidity()
|
||||
if temp is not None and humidity is not None:
|
||||
return temp, humidity
|
||||
time.sleep(1) # Wait a bit before retrying
|
||||
return None, None
|
||||
@@ -1,5 +1,6 @@
|
||||
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
|
||||
@@ -25,8 +26,8 @@ def get_dish_height():
|
||||
# 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:
|
||||
SENSOR_HEIGHT = 30 # cm
|
||||
dish_height = SENSOR_HEIGHT - distance
|
||||
# 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']
|
||||
|
||||
@@ -4,6 +4,17 @@
|
||||
import shared.deviceTypes as deviceTypes
|
||||
import shared.config as config
|
||||
import shared.payloads as payloads
|
||||
import shared.cookingState as cookingState
|
||||
import shared.safeQueue as safeQueue
|
||||
try:
|
||||
import shared.lora_device as lora_device
|
||||
except ImportError:
|
||||
pass # No need
|
||||
try:
|
||||
import shared.uart_comm as uart_comm
|
||||
except ImportError:
|
||||
pass # No need as we are on the RPI
|
||||
import shared.sensors
|
||||
|
||||
def get_lora(*args, **kwargs):
|
||||
from .lora_device import get_lora_device
|
||||
|
||||
+5
-2
@@ -1,7 +1,7 @@
|
||||
DEBUG=True
|
||||
|
||||
# LoRa
|
||||
HEARTBEAT_INTERVAL = 30
|
||||
LORA_HEARTBEAT_INTERVAL = 30
|
||||
|
||||
# MQTT
|
||||
MQTT_BROKER_HOST = "192.168.50.1"
|
||||
@@ -12,4 +12,7 @@ MQTT_KEEPALIVE = 30
|
||||
USE_TLS = True
|
||||
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
|
||||
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"
|
||||
+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"
|
||||
+106
-34
@@ -11,13 +11,10 @@ try:
|
||||
except ImportError:
|
||||
try:
|
||||
from umqtt.simple import MQTTClient as _MQTTClient
|
||||
import _thread
|
||||
import gc
|
||||
BACKEND_NAME = "umqtt.simple"
|
||||
IS_MICROPYTHON = True
|
||||
# except ImportError:
|
||||
# try:
|
||||
# from umqtt.robust import MQTTClient as _MQTTClient
|
||||
# BACKEND_NAME = "umqtt.robust"
|
||||
# IS_MICROPYTHON = True
|
||||
except ImportError as exc:
|
||||
raise ImportError("No MQTT client found. Expected paho.mqtt or umqtt.") from exc
|
||||
|
||||
@@ -79,6 +76,11 @@ class BrokerClient:
|
||||
self._client = None
|
||||
self._callback = None
|
||||
self._messages = []
|
||||
self._cadata = None # Cache cert bytes to prevent heap fragmentation
|
||||
|
||||
# Thread safety lock for MicroPython socket reads/writes
|
||||
if IS_MICROPYTHON:
|
||||
self._lock = _thread.allocate_lock()
|
||||
|
||||
def set_callback(self, callback):
|
||||
self._callback = callback
|
||||
@@ -104,16 +106,24 @@ class BrokerClient:
|
||||
return self._client
|
||||
|
||||
if IS_MICROPYTHON:
|
||||
gc.collect() # Clean Python heap before importing/allocating SSL
|
||||
import ssl
|
||||
ssl_params = self.ssl_params
|
||||
|
||||
if self.use_tls and ssl_params is None:
|
||||
# MicroPython uses context-less structures.
|
||||
# If your CA is self-signed, validation can fail without a valid hostname match.
|
||||
# OPTION A: If broker uses 'require_certificate false' and self-signed certs:
|
||||
# Do NOT pass cadata when cert_reqs is CERT_NONE to save ~20KB of C-DRAM
|
||||
ssl_params = {
|
||||
"cert_reqs": ssl.CERT_NONE, # Temporarily change to NONE to test if validation is the culprit
|
||||
"cadata": _read_file_bytes(self.cafile)
|
||||
"cert_reqs": ssl.CERT_NONE,
|
||||
"server_hostname": self.host
|
||||
}
|
||||
|
||||
# OPTION B: If strict CA validation IS required, load cadata ONLY with CERT_REQUIRED:
|
||||
# ssl_params = {
|
||||
# "cert_reqs": ssl.CERT_REQUIRED,
|
||||
# "cadata": _read_file_bytes(self.cafile),
|
||||
# "server_hostname": self.host
|
||||
# }
|
||||
|
||||
client = _MQTTClient(
|
||||
self.client_id or "smartWave-client",
|
||||
@@ -150,19 +160,35 @@ class BrokerClient:
|
||||
return self._client
|
||||
|
||||
def connect(self):
|
||||
client = self.open()
|
||||
if IS_MICROPYTHON:
|
||||
client.connect()
|
||||
return client
|
||||
gc.collect() # Force C & Python memory cleanup right before TLS handshake
|
||||
|
||||
client.connect(self.host, self.port, self.keepalive)
|
||||
return client
|
||||
if self._client is not None:
|
||||
self.close()
|
||||
|
||||
client = self.open()
|
||||
|
||||
try:
|
||||
if IS_MICROPYTHON:
|
||||
gc.collect() # Sweep memory right before umqtt calls ssl.wrap_socket()
|
||||
with self._lock:
|
||||
client.connect()
|
||||
return client
|
||||
|
||||
client.connect(self.host, self.port, self.keepalive)
|
||||
return client
|
||||
except Exception as e:
|
||||
print("MQTT connection failed, closing client and releasing memory.")
|
||||
print("Exception:", e)
|
||||
self.close()
|
||||
raise
|
||||
|
||||
def publish(self, topic, payload, qos=2, retain=False):
|
||||
client = self.open()
|
||||
payload_bytes = _ensure_bytes(payload)
|
||||
if IS_MICROPYTHON:
|
||||
return client.publish(topic, payload_bytes, retain=retain, qos=qos)
|
||||
with self._lock:
|
||||
return client.publish(topic, payload_bytes, retain=retain, qos=qos)
|
||||
|
||||
if isinstance(topic, bytes):
|
||||
topic = topic.decode('utf-8')
|
||||
@@ -172,8 +198,9 @@ class BrokerClient:
|
||||
def subscribe(self, topic, qos=2):
|
||||
client = self.open()
|
||||
if IS_MICROPYTHON:
|
||||
client.set_callback(self._on_micropython_message)
|
||||
return client.subscribe(topic, qos=qos)
|
||||
with self._lock:
|
||||
client.set_callback(self._on_micropython_message)
|
||||
return client.subscribe(topic, qos=qos)
|
||||
|
||||
if isinstance(topic, bytes):
|
||||
topic = topic.decode('utf-8')
|
||||
@@ -184,27 +211,43 @@ class BrokerClient:
|
||||
client = self.open()
|
||||
if IS_MICROPYTHON:
|
||||
import struct
|
||||
# Ensure the topic is bytes for writing to the socket
|
||||
import time
|
||||
topic_bytes = topic if isinstance(topic, bytes) else topic.encode('utf-8')
|
||||
|
||||
# 1. Build the MQTT unsubscribe packet header
|
||||
# 1. Increment and lock the PID for THIS specific request
|
||||
client.pid = (client.pid % 65535) + 1
|
||||
sent_pid = client.pid # <-- Store local copy
|
||||
|
||||
# 2. Construct UNSUBSCRIBE packet
|
||||
rem_len = 2 + 2 + len(topic_bytes)
|
||||
pkt = bytearray(b"\xa2\0\0\0")
|
||||
client.pid += 1
|
||||
struct.pack_into("!BH", pkt, 1, rem_len, sent_pid)
|
||||
|
||||
# Packet length is: 2 bytes (PID) + 2 bytes (topic length indicator) + topic string length
|
||||
struct.pack_into("!BH", pkt, 1, 2 + 2 + len(topic_bytes), client.pid)
|
||||
|
||||
# 2. Write the packet to the socket
|
||||
# 3. Write packet to socket
|
||||
client.sock.write(pkt)
|
||||
client._send_str(topic_bytes)
|
||||
|
||||
# 3. Wait for the UNSUBACK confirmation frame (0xB0) from the broker
|
||||
while True:
|
||||
# 4. Wait for UNSUBACK (0xB0)
|
||||
start = time.time()
|
||||
while time.time() - start < 3:
|
||||
op = client.wait_msg()
|
||||
if op == 0xB0:
|
||||
resp = client.sock.read(3)
|
||||
assert resp[1] == pkt[2] and resp[2] == pkt[3]
|
||||
resp = bytearray(3)
|
||||
read_bytes = 0
|
||||
while read_bytes < 3:
|
||||
chunk = client.sock.read(3 - read_bytes)
|
||||
if chunk:
|
||||
resp[read_bytes:read_bytes + len(chunk)] = chunk
|
||||
read_bytes += len(chunk)
|
||||
else:
|
||||
time.sleep_ms(10)
|
||||
|
||||
# Compare against sent_pid instead of client.pid
|
||||
resp_pid = (resp[1] << 8) | resp[2]
|
||||
if resp_pid != sent_pid:
|
||||
print(f"[MQTT] UNSUBACK PID mismatch (expected {sent_pid}, got {resp_pid})")
|
||||
return client
|
||||
|
||||
return client
|
||||
|
||||
if isinstance(topic, bytes):
|
||||
@@ -219,14 +262,16 @@ class BrokerClient:
|
||||
if self._client is None:
|
||||
return None
|
||||
if IS_MICROPYTHON:
|
||||
return self._client.check_msg()
|
||||
with self._lock:
|
||||
return self._client.check_msg()
|
||||
return self._client.loop(timeout=timeout)
|
||||
|
||||
def wait(self):
|
||||
if self._client is None:
|
||||
return None
|
||||
if IS_MICROPYTHON:
|
||||
return self._client.wait_msg()
|
||||
with self._lock:
|
||||
return self._client.wait_msg()
|
||||
return self._client.loop_forever()
|
||||
|
||||
def get_message(self):
|
||||
@@ -235,13 +280,40 @@ class BrokerClient:
|
||||
return self._messages.pop(0)
|
||||
|
||||
def close(self):
|
||||
"""Safely clean up socket context without causing ESP32 C panics."""
|
||||
if self._client is None:
|
||||
return
|
||||
try:
|
||||
self._client.disconnect()
|
||||
except Exception:
|
||||
|
||||
if IS_MICROPYTHON:
|
||||
with self._lock:
|
||||
try:
|
||||
if hasattr(self._client, "sock") and self._client.sock:
|
||||
self._client.sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
if hasattr(self._client, "sock"):
|
||||
self._client.sock = None
|
||||
self._client = None
|
||||
gc.collect() # Immediately reclaim freed socket & mbedTLS RAM
|
||||
else:
|
||||
try:
|
||||
self._client.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self._client = None
|
||||
|
||||
def ping(self):
|
||||
"""Thread-safe PINGREQ wrapper for MicroPython."""
|
||||
if self._client is None:
|
||||
return
|
||||
if IS_MICROPYTHON:
|
||||
with self._lock:
|
||||
return self._client.ping()
|
||||
else:
|
||||
# Paho handles keepalives automatically via loop_start/loop
|
||||
pass
|
||||
self._client = None
|
||||
|
||||
def __enter__(self):
|
||||
self.connect()
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
from time import time
|
||||
|
||||
|
||||
try:
|
||||
import ujson as json
|
||||
except ImportError:
|
||||
@@ -20,4 +23,24 @@ def mqtt_hello_ack(id_orchestrator, id_microwave):
|
||||
return as_json({
|
||||
"id_microwave": id_microwave,
|
||||
"id_orchestrator": id_orchestrator
|
||||
})
|
||||
|
||||
def mqtt_cooking_init(id_microwave):
|
||||
return as_json({
|
||||
"id_microwave": id_microwave
|
||||
})
|
||||
|
||||
def mqtt_sensor_data(id_microwave, dish_temp, ambient_temp):
|
||||
return as_json({
|
||||
"id_microwave": id_microwave,
|
||||
"dish_temp": dish_temp,
|
||||
"ambient_temp": ambient_temp
|
||||
})
|
||||
|
||||
def mqtt_cooking_config(id_microwave, cook_time, power_level, target_temp):
|
||||
return as_json({
|
||||
"id_microwave": id_microwave,
|
||||
"cook_time": cook_time,
|
||||
"power_level": power_level,
|
||||
"target_temp": target_temp
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
[console_scripts]
|
||||
normalizer = charset_normalizer.cli:cli_detect
|
||||
@@ -1,21 +0,0 @@
|
||||
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,2 +0,0 @@
|
||||
ada92cb5d92a588d1b93__mypyc
|
||||
charset_normalizer
|
||||
@@ -1,48 +0,0 @@
|
||||
"""
|
||||
Charset-Normalizer
|
||||
~~~~~~~~~~~~~~
|
||||
The Real First Universal Charset Detector.
|
||||
A library that helps you read text from an unknown charset encoding.
|
||||
Motivated by chardet, This package is 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.
|
||||
|
||||
Basic usage:
|
||||
>>> from charset_normalizer import from_bytes
|
||||
>>> results = from_bytes('Bсеки човек има право на образование. Oбразованието!'.encode('utf_8'))
|
||||
>>> best_guess = results.best()
|
||||
>>> str(best_guess)
|
||||
'Bсеки човек има право на образование. Oбразованието!'
|
||||
|
||||
Others methods and usages are available - see the full documentation
|
||||
at <https://github.com/Ousret/charset_normalizer>.
|
||||
:copyright: (c) 2021 by Ahmed TAHRI
|
||||
:license: MIT, see LICENSE for more details.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from .api import from_bytes, from_fp, from_path, is_binary
|
||||
from .legacy import detect
|
||||
from .models import CharsetMatch, CharsetMatches
|
||||
from .utils import set_logging_handler
|
||||
from .version import VERSION, __version__
|
||||
|
||||
__all__ = (
|
||||
"from_fp",
|
||||
"from_path",
|
||||
"from_bytes",
|
||||
"is_binary",
|
||||
"detect",
|
||||
"CharsetMatch",
|
||||
"CharsetMatches",
|
||||
"__version__",
|
||||
"VERSION",
|
||||
"set_logging_handler",
|
||||
)
|
||||
|
||||
# Attach a NullHandler to the top level logger by default
|
||||
# https://docs.python.org/3.3/howto/logging.html#configuring-logging-for-a-library
|
||||
|
||||
logging.getLogger("charset_normalizer").addHandler(logging.NullHandler())
|
||||
@@ -1,6 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .cli import cli_detect
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli_detect()
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user