Compare commits
154 Commits
25e635e207
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e68ffd9b8b | |||
| 49fd566457 | |||
| dd37465546 | |||
| 3753f57041 | |||
| 688fd26db8 | |||
| 4d7706f8e1 | |||
| efbc147f48 | |||
| 4085305039 | |||
| fed99772fc | |||
| 204f271fc2 | |||
| 5c60de3f03 | |||
| 8a71d4eaa7 | |||
| eb80d13300 | |||
| 6e2f849ee2 | |||
| 68250e8069 | |||
| fe31ac978a | |||
| 3c50fc4d0e | |||
| 3aed0e5f00 | |||
| f55336a636 | |||
| c58ae4603e | |||
| e7f6374200 | |||
| 9053351674 | |||
| 1f53e7e6c3 | |||
| 7c35b52dbd | |||
| 0f576467e1 | |||
| 9d1a1f4c78 | |||
| 701e7f1491 | |||
| e652831ce5 | |||
| ebc28546bc | |||
| a51bfc1388 | |||
| 7082ea3f8b | |||
| e802386adf | |||
| 289124b7f8 | |||
| 3bead59b23 | |||
| 467740ba84 | |||
| 29c943ca5a | |||
| c130566156 | |||
| 178457e199 | |||
| c860afc609 | |||
| 9562bb7315 | |||
| a769efa8e0 | |||
| 3711928c7d | |||
| 6b8c1688a6 | |||
| 755ffdad46 | |||
| 29fcb26eae | |||
| 8b3c1d6b63 | |||
| c39b811a3c | |||
| b0a6c4d2ec | |||
| 0b1971ed0b | |||
| d6951cbbcf | |||
| c6cec3dede | |||
| e67a13b4c8 | |||
| 0d394eca1e | |||
| c76379230b | |||
| e042162a77 | |||
| f167f4a2cd | |||
| 7add88f154 | |||
| bb5327fb93 | |||
| 022c45edee | |||
| d7833100a5 | |||
| db173af1c9 | |||
| d0066a9d6d | |||
| f9309c4dbc | |||
| e69bbc6b45 | |||
| 88c3de6ab7 | |||
| 2c0776bdaa | |||
| da93eaa604 | |||
| 608e07cb92 | |||
| 2ee70861cd | |||
| af556fde58 | |||
| bcc3670aff | |||
| ab8199344c | |||
| 5a3f1d94ef | |||
| 50eb4fe87c | |||
| 44a6be9090 | |||
| 9ef4b584a4 | |||
| 4234e1ed21 | |||
| d39c5c26e7 | |||
| 9d609a367b | |||
| b31d939787 | |||
| 647e40131c | |||
| c049339003 | |||
| 3ba65f0106 | |||
| ffbb350520 | |||
| 656afa6e2d | |||
| 8f768a4d35 | |||
| 2047b1b522 | |||
| 3db52f1cad | |||
| 5ae2dc1023 | |||
| 44b4c8034f | |||
| 0149c1e5b2 | |||
| b442d39c18 | |||
| 8b6ab086fd | |||
| 2d8889cd31 | |||
| 83b269cc88 | |||
| 01eccf47c6 | |||
| 9b0ecf944a | |||
| c6e56b58a0 | |||
| 1a77794fd3 | |||
| 0469b3d406 | |||
| fd00c9a89d | |||
| 2276c1742a | |||
| 32229de20d | |||
| d9b596d465 | |||
| 36abfc8234 | |||
| 07ce941950 | |||
| f6c714b1c3 | |||
| 86815a9d4d | |||
| e132d05a85 | |||
| 2d06293325 | |||
| f4c5ce9d35 | |||
| fde1ad6ca0 | |||
| 08f6991ef5 | |||
| 0ec1535436 | |||
| bb25684072 | |||
| 91fa5720f9 | |||
| 5175b32266 | |||
| aa4fd06c94 | |||
| c0daa12f26 | |||
| 547c2df1c9 | |||
| 497c042a2a | |||
| bfc6a75e66 | |||
| 07051a7255 | |||
| 1543dde603 | |||
| 93c1c8e299 | |||
| 6629dd9472 | |||
| f0ae0839b0 | |||
| 2572c68ac7 | |||
| f5bea7f636 | |||
| 1724c6a06a | |||
| f6b29c4e08 | |||
| fe586cf2a5 | |||
| dff6cb9d41 | |||
| 980d79b793 | |||
| 38e10d33e1 | |||
| 40e91a7d2b | |||
| 4abefa5b29 | |||
| ddc136a5ca | |||
| d4cef30b8f | |||
| a6704c3578 | |||
| 1c2c07471e | |||
| 6af00b7990 | |||
| 4d3184cd2f | |||
| 0bc1a88e3c | |||
| 75e64a0706 | |||
| dc30f6ce6c | |||
| 16e3325f8f | |||
| 988cb7958c | |||
| 053964e56c | |||
| ec9be8ac30 | |||
| f30b29be69 | |||
| 46acbb88c2 | |||
| 6ab2a2bea3 | |||
| 35a330e7ae |
@@ -1,4 +1,6 @@
|
||||
|
||||
`export PYTHONPATH=/home/pi/SmartWave`
|
||||
|
||||
`export PYTHONPATH=/home/ninluc/Documents/school/IoT/smartWave`
|
||||
`export PYTHONPATH=/home/ninluc/Documents/school/IoT/smartWave`
|
||||
|
||||
https://red.smartwave.matthiasg.dev/dashboard
|
||||
+82
-60
@@ -1,102 +1,124 @@
|
||||
import os
|
||||
import base64
|
||||
import json
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from typing import Any, Union
|
||||
from openai import OpenAI
|
||||
from pydantic import BaseModel
|
||||
|
||||
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"
|
||||
# Default base_url pointing to Lemonade's OpenAI-compatible endpoint
|
||||
API_HOST = os.getenv("OPENAI_API_HOST", "https://lemonade.matthiasg.dev/api/v1")
|
||||
AI_MODEL = os.getenv("OPENAI_MODEL", "LFM2.5-VL-1.6B-GGUF-Q8_0")
|
||||
AI_MODEL_THINK = os.getenv("OPENAI_MODEL_THINK", "False").lower() in ("true", "1", "t")
|
||||
OPENAPI_TOKEN = os.getenv("OPENAI_API_TOKEN", "lemonade")
|
||||
|
||||
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}")
|
||||
# Initialize OpenAI Client for Lemonade
|
||||
client = OpenAI(
|
||||
base_url=API_HOST,
|
||||
api_key=OPENAPI_TOKEN if OPENAPI_TOKEN else "lemonade"
|
||||
)
|
||||
|
||||
|
||||
def generate(
|
||||
model: str = AI_MODEL,
|
||||
prompt: str = "",
|
||||
images: list[str] = None,
|
||||
output_format: str = None,
|
||||
output_format: Union[str, dict, type[BaseModel]] = 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.
|
||||
Generate a completion response using the OpenAI SDK against Lemonade / OpenAI compatible APIs.
|
||||
Handles multimodal image content, structured outputs, and custom Lemonade parameters.
|
||||
"""
|
||||
if images is None:
|
||||
images = []
|
||||
|
||||
# Transform image file paths to base64 strings
|
||||
encoded_images = []
|
||||
messages = []
|
||||
|
||||
# 1. Add System Message if present
|
||||
if system_message:
|
||||
messages.append({"role": "system", "content": system_message})
|
||||
|
||||
# 2. Build User Content Payload (Text + Multimodal Images)
|
||||
user_content = []
|
||||
if prompt:
|
||||
user_content.append({"type": "text", "text": prompt})
|
||||
|
||||
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"))
|
||||
b64_str = 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)
|
||||
b64_str = img_path
|
||||
|
||||
body = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"images": encoded_images,
|
||||
# Ensure base64 string includes Data URI prefix for OpenAI vision format
|
||||
if not b64_str.startswith("data:"):
|
||||
image_url = f"data:image/jpeg;base64,{b64_str}"
|
||||
else:
|
||||
image_url = b64_str
|
||||
|
||||
user_content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": image_url}
|
||||
})
|
||||
|
||||
# Simplify content payload if text-only
|
||||
if len(user_content) == 1 and user_content[0]["type"] == "text":
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
else:
|
||||
messages.append({"role": "user", "content": user_content})
|
||||
|
||||
# 3. Prepare parameters and custom body flags
|
||||
extra_body = {
|
||||
"think": should_think,
|
||||
"stream": False,
|
||||
}
|
||||
if not keep_alive:
|
||||
extra_body["keep_alive"] = "0m"
|
||||
|
||||
request_kwargs: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"extra_body": extra_body,
|
||||
}
|
||||
|
||||
if system_message is not None:
|
||||
body["system"] = system_message
|
||||
|
||||
# 4. Handle Structured Outputs / JSON mode
|
||||
if output_format is not None:
|
||||
try:
|
||||
body["format"] = json.loads(output_format)
|
||||
except json.JSONDecodeError:
|
||||
body["format"] = output_format
|
||||
if isinstance(output_format, type) and issubclass(output_format, BaseModel):
|
||||
# Pydantic Model -> Use structured output parsing API
|
||||
response = client.beta.chat.completions.parse(
|
||||
response_format=output_format,
|
||||
**request_kwargs
|
||||
)
|
||||
return response.choices[0].message.content or ""
|
||||
|
||||
elif isinstance(output_format, dict):
|
||||
# JSON Schema dictionary
|
||||
request_kwargs["response_format"] = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "structured_response",
|
||||
"strict": True,
|
||||
"schema": output_format,
|
||||
}
|
||||
}
|
||||
elif isinstance(output_format, str) and output_format.lower() == "json":
|
||||
# Standard JSON Mode
|
||||
request_kwargs["response_format"] = {"type": "json_object"}
|
||||
|
||||
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", "")
|
||||
# Standard completion request
|
||||
response = client.chat.completions.create(**request_kwargs)
|
||||
return response.choices[0].message.content or ""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example usage:
|
||||
result = generate(
|
||||
prompt="Explain what you see in the image or answer this prompt.",
|
||||
prompt="Explain what you see or answer this prompt.",
|
||||
should_think=AI_MODEL_THINK,
|
||||
)
|
||||
print(result)
|
||||
+10
-10
@@ -6,7 +6,7 @@ import json
|
||||
|
||||
EDAMAM_APP_ID__FOOD = os.getenv("EDAMAM_APP_ID__FOOD", None)
|
||||
EDAMAM_APP_KEY__FOOD = os.getenv("EDAMAM_APP_KEY__FOOD", None)
|
||||
SAVE_EDAMAM_API_TOKEN = os.getenv("SAVE_EDAMAM_API_TOKEN", False)
|
||||
SAVEUP_EDAMAM_API_TOKEN = os.getenv("SAVEUP_EDAMAM_API_TOKEN", "false").lower() == "true"
|
||||
|
||||
class EdamamAPI:
|
||||
"""Wrapper around Edamam Food Database API 2.0 (Vision & Nutrients)"""
|
||||
@@ -17,7 +17,7 @@ class EdamamAPI:
|
||||
pass
|
||||
|
||||
def analyze_dish_image(self, image_file_path: str):
|
||||
if SAVE_EDAMAM_API_TOKEN:
|
||||
if SAVEUP_EDAMAM_API_TOKEN:
|
||||
time.sleep(3)
|
||||
return json.loads("""
|
||||
{
|
||||
@@ -911,15 +911,15 @@ class EdamamAPI:
|
||||
|
||||
""")
|
||||
|
||||
url = "https://api.edamam.com/api/vision/v1/nutrients"
|
||||
url = "https://api.edamam.com/api/vision/v1/nutrients?app_id=09cb5bd7&app_key=8b2730f3c7f4f075cc939da3fa0c49fb&beta=true&servings=1"
|
||||
|
||||
# Query parameters as specified by the YAML schema
|
||||
params = {
|
||||
"app_id": EDAMAM_APP_ID__FOOD,
|
||||
"app_key": EDAMAM_APP_KEY__FOOD,
|
||||
"beta": "true" # Recommended since vision is in Beta
|
||||
}
|
||||
print("params:", params)
|
||||
# params = {
|
||||
# "app_id": EDAMAM_APP_ID__FOOD,
|
||||
# "app_key": EDAMAM_APP_KEY__FOOD,
|
||||
# "beta": "true" # Recommended since vision is in Beta
|
||||
# }
|
||||
# print("params:", params)
|
||||
|
||||
# Specify the media type in the header
|
||||
headers = {
|
||||
@@ -932,7 +932,7 @@ class EdamamAPI:
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
params=params,
|
||||
# params=params,
|
||||
headers=headers,
|
||||
data=raw_image_bytes
|
||||
)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import json
|
||||
import os
|
||||
import paho.mqtt.publish as publish
|
||||
import sys
|
||||
|
||||
# Retrieve broker parameters from shared config or environment variables
|
||||
MQTT_HOST = os.getenv("MQTT_HOST", "localhost")
|
||||
MQTT_PORT = int(os.getenv("MQTT_PORT", 8884))
|
||||
MQTT_USER = os.getenv("MQTT_USER", None)
|
||||
MQTT_PASS = os.getenv("MQTT_PASS", None)
|
||||
|
||||
|
||||
def send_command(topic: str = "cmd/all", payload: dict | str = None):
|
||||
"""
|
||||
Publishes an MQTT command payload to a given topic.
|
||||
"""
|
||||
if payload is None:
|
||||
payload = {}
|
||||
|
||||
if isinstance(payload, dict):
|
||||
payload_str = json.dumps(payload)
|
||||
else:
|
||||
payload_str = str(payload)
|
||||
|
||||
auth = None
|
||||
if MQTT_USER:
|
||||
auth = {"username": MQTT_USER, "password": MQTT_PASS or ""}
|
||||
|
||||
publish.single(
|
||||
topic=topic,
|
||||
payload=payload_str,
|
||||
hostname=MQTT_HOST,
|
||||
port=MQTT_PORT,
|
||||
auth=auth
|
||||
)
|
||||
print(f"[MQTT] Published command to '{topic}': {payload_str}")
|
||||
@@ -0,0 +1,134 @@
|
||||
import os
|
||||
import requests
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
|
||||
class ShodanAuditor:
|
||||
"""Service class to handle Shodan API security audits and IP profiling."""
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None):
|
||||
self.api_key = api_key or os.getenv("SHODAN_API_KEY", "")
|
||||
self.base_url = "https://api.shodan.io"
|
||||
|
||||
def get_public_ip(self) -> Optional[str]:
|
||||
"""Utility method to discover the server's public IPv4 address."""
|
||||
try:
|
||||
res = requests.get("https://api.ipify.org?format=json", timeout=5)
|
||||
return res.json().get("ip")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _query_host(self, ip: str) -> Dict[str, Any]:
|
||||
"""Internal helper to query Shodan's Host API endpoint."""
|
||||
if not self.api_key:
|
||||
return {"error": "Missing Shodan API key"}
|
||||
|
||||
url = f"{self.base_url}/shodan/host/{ip}?key={self.api_key}"
|
||||
try:
|
||||
response = requests.get(url, timeout=8)
|
||||
if response.status_code == 404:
|
||||
return {"indexed": False, "ip": ip}
|
||||
if response.status_code != 200:
|
||||
return {"error": f"Shodan API error HTTP {response.status_code}"}
|
||||
|
||||
data = response.json()
|
||||
data["indexed"] = True
|
||||
return data
|
||||
except requests.RequestException as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
def audit_server_vulnerabilities(self, target_ip: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Audits the server for reported CVEs, exposed services, and SSL details.
|
||||
|
||||
:param target_ip: IP to scan (defaults to server's own public IP)
|
||||
"""
|
||||
ip = target_ip or self.get_public_ip()
|
||||
if not ip:
|
||||
return {"status": "error", "message": "Unable to resolve target IP"}
|
||||
|
||||
data = self._query_host(ip)
|
||||
|
||||
if "error" in data:
|
||||
return {"status": "error", "message": data["error"], "target_ip": ip}
|
||||
|
||||
if not data.get("indexed"):
|
||||
return {
|
||||
"status": "PASS",
|
||||
"target_ip": ip,
|
||||
"message": "IP is not indexed by Shodan (low public exposure)",
|
||||
"vulnerabilities": [],
|
||||
"open_ports": []
|
||||
}
|
||||
|
||||
# Extract CVEs
|
||||
vulns = data.get("vulns", [])
|
||||
|
||||
# Extract SSL/TLS info from HTTP services if available
|
||||
ssl_info = []
|
||||
for service in data.get("data", []):
|
||||
if "ssl" in service:
|
||||
ssl_details = service["ssl"]
|
||||
ssl_info.append({
|
||||
"port": service.get("port"),
|
||||
"cert_expired": ssl_details.get("cert", {}).get("expired", False),
|
||||
"cipher": ssl_details.get("cipher", {}).get("name"),
|
||||
"version": ssl_details.get("versions")
|
||||
})
|
||||
|
||||
return {
|
||||
"status": "FAIL" if vulns else "PASS",
|
||||
"target_ip": ip,
|
||||
"hostnames": data.get("hostnames", []),
|
||||
"open_ports": data.get("ports", []),
|
||||
"vulnerabilities": vulns,
|
||||
"vuln_count": len(vulns),
|
||||
"ssl_certificates": ssl_info,
|
||||
"last_shodan_update": data.get("last_update")
|
||||
}
|
||||
|
||||
def profile_client_ip(self, client_ip: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Profiles a incoming client (microwave) public IP address.
|
||||
Checks if the client network has exposed admin panels, Telnet, or CVEs.
|
||||
|
||||
:param client_ip: Public IP of the connecting device/router
|
||||
"""
|
||||
# Skip local/private network IPs
|
||||
if client_ip in ["127.0.0.1", "localhost"] or client_ip.startswith(("10.", "192.168.", "172.16.")):
|
||||
return {"status": "skipped", "reason": "Local/Private IP"}
|
||||
|
||||
data = self._query_host(client_ip)
|
||||
|
||||
if "error" in data or not data.get("indexed"):
|
||||
return {
|
||||
"client_ip": client_ip,
|
||||
"risk_level": "LOW",
|
||||
"indexed": False,
|
||||
"summary": "Client IP not indexed on Shodan"
|
||||
}
|
||||
|
||||
ports = data.get("ports", [])
|
||||
vulns = data.get("vulns", [])
|
||||
|
||||
# Check for high-risk exposed router/device ports
|
||||
high_risk_ports = [23, 80, 8080, 8443, 7547] # Telnet, Web UI, TR-069
|
||||
flagged_ports = [p for p in ports if p in high_risk_ports]
|
||||
|
||||
# Calculate basic risk level
|
||||
if vulns or 23 in ports:
|
||||
risk_level = "HIGH"
|
||||
elif flagged_ports:
|
||||
risk_level = "MEDIUM"
|
||||
else:
|
||||
risk_level = "LOW"
|
||||
|
||||
return {
|
||||
"client_ip": client_ip,
|
||||
"risk_level": risk_level,
|
||||
"indexed": True,
|
||||
"open_ports": ports,
|
||||
"flagged_router_ports": flagged_ports,
|
||||
"vulnerabilities": vulns,
|
||||
"org": data.get("org", "Unknown ISP")
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import os
|
||||
from twilio.rest import Client
|
||||
|
||||
def send_alert_sms(to_phone: str, room_title: str, room_link: str = None, client_email: str = None) -> bool:
|
||||
"""
|
||||
Sends an SMS alert using Twilio notifying the client of an issue and support room creation.
|
||||
"""
|
||||
account_sid = os.environ.get("TWILIO_SID")
|
||||
auth_token = os.environ.get("TWILIO_AUTH_TOKEN")
|
||||
from_phone = os.environ.get("TWILIO_PHONE_NUMBER") # Requires your Twilio sender phone number
|
||||
|
||||
if not account_sid or not auth_token or not from_phone:
|
||||
print("Twilio environment variables (TWILIO_SID, TWILIO_ACCOUNT_SECRET, TWILIO_PHONE_NUMBER) missing.")
|
||||
return False
|
||||
|
||||
if not to_phone:
|
||||
print("No recipient phone number provided for SMS.")
|
||||
return False
|
||||
|
||||
# --- BUILD SMS CONTENT ---
|
||||
body = (
|
||||
f"Alert: A technical issue occurred with your SmartWave equipment/microwave. "
|
||||
f"A support chat room '{room_title}' has been created to assist you.\n\n"
|
||||
)
|
||||
|
||||
if room_link:
|
||||
body += f"Join chat room: {room_link}\n"
|
||||
|
||||
if client_email:
|
||||
body += f"An invitation link has also been sent to your email ({client_email})."
|
||||
else:
|
||||
body += "An invitation link has been sent to your inbox."
|
||||
|
||||
print(f"Sending SMS to {to_phone} : \"{body}\"")
|
||||
|
||||
try:
|
||||
client = Client(account_sid, auth_token)
|
||||
message = client.messages.create(
|
||||
to=to_phone,
|
||||
from_=from_phone,
|
||||
body="sms_internal_alerts",
|
||||
)
|
||||
print(f"SMS sent successfully to {to_phone}. Message SID: {message.sid}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Failed to send SMS via Twilio to {to_phone}: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,147 @@
|
||||
import time
|
||||
import datetime
|
||||
import requests
|
||||
from flask import current_app
|
||||
|
||||
class WebexManager:
|
||||
def __init__(self, db_collection, client_id, client_secret, redirect_uri, team_id, user_id):
|
||||
self.db = db_collection
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.redirect_uri = redirect_uri
|
||||
self.team_id = team_id
|
||||
self.user_id = user_id
|
||||
self.base_url = "https://webexapis.com/v1"
|
||||
|
||||
def save_tokens(self, token_data: dict):
|
||||
"""Saves or updates Webex OAuth tokens in MongoDB."""
|
||||
now = time.time()
|
||||
doc = {
|
||||
"_id": "webex_credentials",
|
||||
"access_token": token_data.get("access_token"),
|
||||
"refresh_token": token_data.get("refresh_token"),
|
||||
"expires_at": now + token_data.get("expires_in", 43200) - 300, # Subtract 5 mins buffer
|
||||
"refresh_token_expires_at": now + token_data.get("refresh_token_expires_in", 7776000),
|
||||
"updated_at": datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
}
|
||||
self.db.replace_one({"_id": "webex_credentials"}, doc, upsert=True)
|
||||
return doc
|
||||
|
||||
def get_access_token(self) -> str:
|
||||
"""Retrieves a valid access token from MongoDB, auto-refreshing if expired."""
|
||||
token_doc = self.db.find_one({"_id": "webex_credentials"})
|
||||
if not token_doc:
|
||||
raise Exception("No Webex OAuth tokens found. Please complete login at /oauth/login.")
|
||||
|
||||
# Check if access token is expired
|
||||
if time.time() >= token_doc.get("expires_at", 0):
|
||||
current_app.logger.info("Webex access token expired. Refreshing...")
|
||||
return self.refresh_token(token_doc.get("refresh_token"))
|
||||
|
||||
return token_doc.get("access_token")
|
||||
|
||||
def exchange_code(self, code: str) -> dict:
|
||||
"""Exchanges OAuth code for access and refresh tokens."""
|
||||
payload = {
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"code": code,
|
||||
"redirect_uri": self.redirect_uri
|
||||
}
|
||||
res = requests.post(f"{self.base_url}/access_token", data=payload)
|
||||
|
||||
if not res.ok:
|
||||
# Print or log the exact Webex error response
|
||||
print(f"Webex Error Details ({res.status_code}): {res.text}")
|
||||
res.raise_for_status()
|
||||
|
||||
return self.save_tokens(res.json())
|
||||
|
||||
def refresh_token(self, refresh_token: str) -> str:
|
||||
"""Refreshes the Webex access token using the stored refresh_token."""
|
||||
payload = {
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"refresh_token": refresh_token
|
||||
}
|
||||
res = requests.post(f"{self.base_url}/access_token", data=payload)
|
||||
res.raise_for_status()
|
||||
new_doc = self.save_tokens(res.json())
|
||||
return new_doc["access_token"]
|
||||
|
||||
def create_support_room(self, client_name: str, client_email: str, alert_type: str, alert_message: str, alert_id: str) -> str:
|
||||
"""Creates a team support room and invites the client and yourself."""
|
||||
access_token = self.get_access_token()
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# 1. Create room under team
|
||||
room_res = requests.post(
|
||||
f"{self.base_url}/rooms",
|
||||
headers=headers,
|
||||
json={
|
||||
"title": f"Smartwave Support: {client_name} - {alert_type} ({alert_id})",
|
||||
"teamId": self.team_id
|
||||
}
|
||||
)
|
||||
room_res.raise_for_status()
|
||||
room_details = room_res.json()
|
||||
room_id = room_details["id"]
|
||||
|
||||
# 2. Add Client via email (Webex sends invite if user does not exist)
|
||||
if client_email:
|
||||
requests.post(
|
||||
f"{self.base_url}/memberships",
|
||||
headers=headers,
|
||||
json={"roomId": room_id, "personEmail": client_email}
|
||||
)
|
||||
|
||||
# 3. Add yourself (WEBEX_NINLUC_ID)
|
||||
if self.user_id:
|
||||
requests.post(
|
||||
f"{self.base_url}/memberships",
|
||||
headers=headers,
|
||||
json={"roomId": room_id, "personId": self.user_id}
|
||||
)
|
||||
|
||||
# 4. Optional: Send initial welcome message in the room
|
||||
self.send_message(
|
||||
room_id,
|
||||
message=f"Support room initialized for {client_name}. An agent will be with you shortly.",
|
||||
access_token=access_token
|
||||
)
|
||||
self.send_alert_info_message(room_id, alert_type, alert_message, alert_id, access_token=access_token)
|
||||
|
||||
return room_details
|
||||
|
||||
def send_message(self, room_id: str, message: str | None = None, markdown: str | None = None, access_token: str | None = None):
|
||||
# Fetch the token so it auto-refreshes if needed
|
||||
if not access_token:
|
||||
access_token = self.get_access_token()
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
requests.post(
|
||||
f"{self.base_url}/messages",
|
||||
headers=headers,
|
||||
json={
|
||||
"roomId": room_id,
|
||||
"text": message,
|
||||
"markdown": markdown
|
||||
}
|
||||
).raise_for_status()
|
||||
|
||||
def send_alert_info_message(self, room_id: str, alert_type: str, alert_message: str, alert_id: str, added_alert_info: bool = False, access_token: str | None = None):
|
||||
"""Sends a structured alert info message to the specified Webex room."""
|
||||
if added_alert_info:
|
||||
message = f"Additional Alert Details:\n- Type: {alert_type}\n- Message: {alert_message}\n- Alert ID: {alert_id}"
|
||||
else:
|
||||
message = f"Alert Details:\n- Type: {alert_type}\n- Message: {alert_message}\n- Alert ID: {alert_id}"
|
||||
|
||||
self.send_message(room_id, message=message, access_token=access_token)
|
||||
+41
-1
@@ -1,4 +1,44 @@
|
||||
|
||||
in `/cloud` folder :
|
||||
|
||||
`flask run --debug`
|
||||
`flask run --debug`
|
||||
|
||||
# Oauth Webex
|
||||
|
||||
[webexapis.com](https://webexapis.com/v1/authorize?client_id=C2a4f544bb4f65503e60b6dceded0de194323a873360b2d06eff5a2232ff46176&response_type=code&redirect_uri=https%3A%2F%2Fsmartwave.matthiasg.dev%2Foauth%2Fcallback&scope=spark-admin%3Abroadworks_subscribers_write%20meeting%3Aadmin_participants_read%20spark%3Awebhooks_read%20spark%3Apeople_write%20spark-admin%3Awholesale_customers_write%20meeting%3Asummaries_write%20spark-admin%3Aworkspace_metrics_read%20cjp%3Atask_read%20spark-admin%3Awholesale_billing_reports_read%20meeting%3Asummaries_read%20spark-admin%3Atelephony_pstn_write%20spark-admin%3Avideo_mesh_api_webhook_write%20spark%3Aplaces_read%20cjp%3Aconfig_write%20meeting%3Aadmin_config_write%20spark-admin%3Arecordings_read%20spark-compliance%3Amessages_read%20cjds%3Aadmin_org_read%20spark-admin%3Aworkspaces_write%20spark-compliance%3Ameetings_write%20spark-admin%3Alocations_write%20meeting%3Aadmin_schedule_write%20identity%3Aplaceonetimepassword_create%20spark-admin%3Aorganizations_write%20spark-admin%3Atelephony_pstn_read%20spark%3Adevices_write%20spark-admin%3Awholesale_sub_partners_write%20cjp%3Aconfig_read%20spark-admin%3Abroadworks_billing_reports_write%20spark-admin%3Acall_qualities_read%20spark%3Aapplications_token%20spark%3Akms%20spark-admin%3Awholesale_sub_partners_read%20spark%3Awebrtc_calling%20spark-admin%3Amessages_read%20application%3Awebhooks_write%20spark-admin%3Awholesale_subscribers_read%20spark-admin%3Acalls_write%20meeting%3Aadmin_config_read%20spark%3Atelephony_config_write%20meeting%3Aparticipants_read%20Identity%3Acontact%20spark-admin%3Awholesale_billing_reports_write%20spark-admin%3Avideo_mesh_api_read%20Identity%3AConfig%20spark-compliance%3Awebhooks_write%20spark-admin%3Aorganizations_read%20cjds%3Aadmin_org_write%20spark-compliance%3Ateam_memberships_read%20meeting%3Aschedules_write%20spark-admin%3Avideo_mesh_api_write%20spark-admin%3Arecordings_write%20identity%3Aorganizations_rw%20identity%3Aorganizations_read%20cloud-contact-center%3Apod_conv%20spark-admin%3Adevices_read%20cjp%3Auser%20cjp%3Aconfig%20spark-admin%3Atelephony_config_write%20spark%3Arecordings_write%20spark-admin%3Abroadworks_enterprises_read%20spark%3Acalls_read%20meeting%3Arecordings_write%20spark%3Adevices_read%20spark-compliance%3Aevents_read%20spark-admin%3Aresource_group_memberships_read%20identity%3Apeople_rw%20spark-admin%3Aresource_group_memberships_write%20application%3Awebhooks_read%20spark-admin%3Ahybrid_connectors_read%20spark-compliance%3Ateams_read%20spark-admin%3Aplaces_write%20spark%3Amcp%20spark-admin%3Alicenses_read%20spark%3Aplaces_write%20webexsquare%3Aadmin%20spark%3Awebhooks_write%20meeting%3Aadmin_preferences_write%20spark%3Aall%20meeting%3Aadmin_preferences_read%20analytics%3Aread_all%20spark-admin%3Apeople_write%20spark%3Aorganizations_read%20spark-admin%3Aplaces_read%20spark-admin%3Awholesale_workspace_write%20spark-compliance%3Ateam_memberships_write%20identity%3Agroups_read%20identity%3Atokens_read%20wxc-dedicateduc%3Aadmin_perfmon_read%20spark-compliance%3Arecordings_read%20spark-admin%3Adevices_write%20spark%3Acalls_write%20Identity%3Aone_time_password%20spark-admin%3Aworkspace_locations_read%20spark-compliance%3Arecordings_write%20spark%3Axapi_commands%20spark-compliance%3Awebhooks_read%20spark-compliance%3Amessages_write%20spark-admin%3Awholesale_customers_read%20spark%3Axsi%20spark-admin%3Areports_read%20meeting%3Aparticipants_write%20spark-admin%3Apeople_read%20meeting%3Aadmin_transcripts_read%20spark-compliance%3Amemberships_read%20spark-admin%3Aresource_groups_read%20meeting%3Arecordings_read%20spark-admin%3Alocations_read%20meeting%3Apreferences_write%20meeting%3Aadmin_recordings_read%20identity%3Atokens_write%20meeting%3Atranscripts_read%20spark-admin%3Amessages_write%20spark%3Axapi_statuses%20spark-admin%3Acalls_read%20spark-admin%3Awholesale_subscribers_write%20spark-admin%3Acalling_cdr_read%20spark%3Arecordings_read%20cjp%3Atask_write%20spark-admin%3Ahybrid_clusters_read%20meeting%3Acontrols_read%20spark-admin%3Aworkspace_locations_write%20spark-admin%3Abroadworks_billing_reports_read%20spark-admin%3Atelephony_config_read%20spark-admin%3Abroadworks_enterprises_write%20meeting%3Aadmin_schedule_read%20spark-compliance%3Amemberships_write%20meeting%3Aschedules_read%20Identity%3AOrganization%20spark-admin%3Aroles_read%20spark-compliance%3Ameetings_read%20meeting%3Apreferences_read%20identity%3Apeople_read%20spark-admin%3Aworkspaces_read%20spark%3Atelephony_config_read%20spark-compliance%3Arooms_read%20spark-admin%3Abroadworks_subscribers_read%20identity%3Agroups_rw%20spark-admin%3Areports_write%20meeting%3Acontrols_write%20audit%3Aevents_read%20meeting%3Aadmin_recordings_write%20spark-compliance%3Arooms_write&state=set_state_here)
|
||||
|
||||
# Tests
|
||||
|
||||
## Curl alerte
|
||||
|
||||
```bash
|
||||
curl -X POST "https://smartwave.matthiasg.dev/alert" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"orchestrator_id": "1",
|
||||
"alert": {
|
||||
"type": "TEST",
|
||||
"message": "C’est un test",
|
||||
"timestamp": 12345
|
||||
},
|
||||
"logs": []
|
||||
}'
|
||||
```
|
||||
|
||||
|
||||
## Jobs
|
||||
|
||||
### Trigger Server CVE Audit
|
||||
`curl https://smartwave.matthiasg.dev/debug/run-job/server_cve_audit_job`
|
||||
|
||||
### Trigger Client IP Audit
|
||||
`curl https://smartwave.matthiasg.dev/debug/run-job/client_ip_audit_job`
|
||||
|
||||
### Trigger Telemetry Request
|
||||
`curl https://smartwave.matthiasg.dev/debug/run-job/request_telemetry_job`
|
||||
|
||||
# Faux bols
|
||||
|
||||
- Riz
|
||||
|
||||
- Pot Beef Burgundy
|
||||
+609
-73
@@ -1,11 +1,27 @@
|
||||
import os
|
||||
import base64
|
||||
import uuid
|
||||
from flask import Flask, request, jsonify
|
||||
from pymongo import MongoClient
|
||||
from APIs import generate, EdamamAPI
|
||||
import sys
|
||||
import json
|
||||
import uuid
|
||||
import base64
|
||||
import asyncio
|
||||
import fcntl
|
||||
import atexit
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from flask import Flask, Response, request, jsonify, current_app, abort, g
|
||||
from pymongo import MongoClient, ASCENDING, DESCENDING
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
from APIs import generate
|
||||
from APIs.mqtt import send_command
|
||||
from APIs.webex import WebexManager
|
||||
from APIs.shodan import ShodanAuditor
|
||||
from APIs.twilio import send_alert_sms
|
||||
from image_enhancer import enhance_image_for_ai
|
||||
from microwaveCookPlanner import MicrowaveCookPlanner
|
||||
import safety_checker
|
||||
from jobs import job_server_cve_audit, job_client_ip_audit, job_request_telemetry
|
||||
|
||||
sys.path.insert(0, '..')
|
||||
try:
|
||||
from shared import config
|
||||
@@ -18,114 +34,634 @@ app = Flask(__name__)
|
||||
# Configuration & Setup
|
||||
# ---------------------------------------------------------
|
||||
|
||||
# Configure MongoDB connection (adjust the URI as needed for your environment)
|
||||
# Configure MongoDB connection
|
||||
MONGO_URI = os.getenv("MONGO_URI", "mongodb://localhost:27017/")
|
||||
client = MongoClient(MONGO_URI)
|
||||
db = client["microwave_network_db"]
|
||||
|
||||
# Database Collections
|
||||
client_collection = db["client_data"]
|
||||
cooking_collection = db["cooking_parameters"]
|
||||
device_network_collection = db["device_network"]
|
||||
telemetry_collection = db["telemetry_data"]
|
||||
alert_collection = db["alert_data"]
|
||||
webex_tokens_collection = db["webex_tokens"]
|
||||
device_ip_collection = db["device_ips"]
|
||||
cve_audit_results = db["cve_audit_results"]
|
||||
rfid_cards_collection = db["rfid_cards"]
|
||||
|
||||
# Initialize Database Indexes
|
||||
def init_db_indexes():
|
||||
device_ip_collection.create_index([("received_at", DESCENDING)])
|
||||
device_ip_collection.create_index([("client_ip", ASCENDING)])
|
||||
alert_collection.create_index([("orchestrator_id", ASCENDING), ("created_at", DESCENDING)])
|
||||
client_collection.create_index([("devices.device_id", ASCENDING)])
|
||||
|
||||
init_db_indexes()
|
||||
|
||||
# Environment Configurations
|
||||
WEBEX_CLIENT_ID = os.getenv("WEBEX_CLIENT_ID", "YOUR_WEBEX_CLIENT_ID")
|
||||
WEBEX_CLIENT_SECRET = os.getenv("WEBEX_CLIENT_SECRET", "YOUR_WEBEX_CLIENT_SECRET")
|
||||
WEBEX_REDIRECT_URI = os.getenv("WEBEX_REDIRECT_URI", "https://smartwave.matthiasg.dev/oauth/callback")
|
||||
WEBEX_TEAM_ID = os.getenv("WEBEX_TEAM_ID", "YOUR_WEBEX_TEAM_ID")
|
||||
WEBEX_NINLUC_ID = os.getenv("WEBEX_NINLUC_ID", "YOUR_WEBEX_NINLUC_ID")
|
||||
SAVEUP_TWILIO_API_TOKEN = os.getenv("SAVEUP_TWILIO_API_TOKEN", "false").lower() == "true"
|
||||
|
||||
# Ensure the camera image storage directory exists when the app starts
|
||||
CAMERA_IMAGE_DIR = "storage/dishCameraImages"
|
||||
CAMERA_IMAGE_DIR = "storage/dishPhotos"
|
||||
os.makedirs(CAMERA_IMAGE_DIR, exist_ok=True)
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Classes
|
||||
# Integrations Setup
|
||||
# ---------------------------------------------------------
|
||||
microwave_cook_planner = MicrowaveCookPlanner()
|
||||
webex_manager = WebexManager(
|
||||
db_collection=webex_tokens_collection,
|
||||
client_id=WEBEX_CLIENT_ID,
|
||||
client_secret=WEBEX_CLIENT_SECRET,
|
||||
redirect_uri=WEBEX_REDIRECT_URI,
|
||||
team_id=WEBEX_TEAM_ID,
|
||||
user_id=WEBEX_NINLUC_ID
|
||||
)
|
||||
shodan_auditor = ShodanAuditor()
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Routes
|
||||
# Cron Scheduler (Multi-Worker Safe)
|
||||
# ---------------------------------------------------------
|
||||
scheduler = BackgroundScheduler(daemon=True)
|
||||
|
||||
def start_scheduler_once():
|
||||
"""Ensures only ONE Gunicorn worker process runs the cron scheduler."""
|
||||
lock_file_path = "/tmp/scheduler.lock"
|
||||
|
||||
# Open or create a lock file
|
||||
lock_file = open(lock_file_path, "wb")
|
||||
|
||||
try:
|
||||
# Request a non-blocking exclusive lock
|
||||
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
|
||||
# Register job schedules
|
||||
scheduler.add_job(
|
||||
func=job_server_cve_audit,
|
||||
args=[shodan_auditor, db],
|
||||
trigger="cron",
|
||||
hour=0,
|
||||
minute=0,
|
||||
id="server_cve_audit_job",
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
scheduler.add_job(
|
||||
func=job_client_ip_audit,
|
||||
args=[shodan_auditor, db],
|
||||
trigger="cron",
|
||||
hour="*/6",
|
||||
minute=15,
|
||||
id="client_ip_audit_job",
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
scheduler.add_job(
|
||||
func=job_request_telemetry,
|
||||
args=[send_command],
|
||||
trigger="cron",
|
||||
hour="1,13",
|
||||
minute=0,
|
||||
id="request_telemetry_job",
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
scheduler.start()
|
||||
print(f"[Cron] Scheduler started successfully in Worker PID {os.getpid()}")
|
||||
|
||||
# Ensure lock release on shutdown
|
||||
def cleanup():
|
||||
try:
|
||||
fcntl.flock(lock_file, fcntl.LOCK_UN)
|
||||
lock_file.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
atexit.register(cleanup)
|
||||
|
||||
except (IOError, OSError):
|
||||
# Lock acquired by another worker - skip starting scheduler
|
||||
print(f"[Cron] Worker PID {os.getpid()} skipped scheduler (already running in another worker).")
|
||||
|
||||
# Initialize single-instance scheduler
|
||||
start_scheduler_once()
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Authentication Middleware
|
||||
# ---------------------------------------------------------
|
||||
EXEMPT_ROUTES = {'hello_world', 'oauth_callback', 'odata_metadata', 'debug_telemetryrequest', 'trigger_job_manually', 'debug_unsafe_dish', 'debug_safe_dish'}
|
||||
|
||||
@app.before_request
|
||||
def authenticate_request():
|
||||
if request.endpoint in EXEMPT_ROUTES or request.method == 'OPTIONS':
|
||||
return
|
||||
|
||||
# Extract API Key from Header (X-API-Key or Bearer token)
|
||||
api_key = request.headers.get("X-API-Key")
|
||||
if not api_key:
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
api_key = auth_header.split(" ")[1]
|
||||
|
||||
if not api_key:
|
||||
return jsonify({
|
||||
"@odata.error": {
|
||||
"code": "401",
|
||||
"message": "Unauthorized: Missing API Key header"
|
||||
}
|
||||
}), 401
|
||||
|
||||
# Validate key against 'devices_authentication' collection
|
||||
device_auth = db.devices_authentication.find_one({"api_key": api_key})
|
||||
if not device_auth:
|
||||
return jsonify({
|
||||
"@odata.error": {
|
||||
"code": "401",
|
||||
"message": "Unauthorized: Invalid API Key"
|
||||
}
|
||||
}), 401
|
||||
|
||||
# Attach device context (works for both Microwaves and Node-RED)
|
||||
g.device_id = device_auth.get("device_id")
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Application Routes
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@app.route("/")
|
||||
def hello_world():
|
||||
gen = generate(prompt="Say Hello, to the user !")
|
||||
print(gen)
|
||||
return f"<p>{gen}</p>"
|
||||
|
||||
|
||||
@app.route("/oauth/callback")
|
||||
def oauth_callback():
|
||||
code = request.args.get("code")
|
||||
if not code:
|
||||
return jsonify({"error": "Missing code parameter"}), 400
|
||||
|
||||
try:
|
||||
webex_manager.exchange_code(code)
|
||||
return jsonify({"status": "success", "message": "Webex tokens stored successfully!"}), 200
|
||||
except Exception as e:
|
||||
current_app.logger.exception("Failed to exchange OAuth code")
|
||||
return jsonify({"error": "OAuth exchange failed"}), 500
|
||||
|
||||
|
||||
@app.route("/cooking-params", methods=["POST"])
|
||||
def cooking_params():
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return jsonify({"error": "Invalid or missing JSON payload"}), 400
|
||||
async def cooking_params():
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data or not isinstance(data, dict):
|
||||
return jsonify({"@odata.error": {"code": "400", "message": "Invalid 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)
|
||||
# data["microwave_id"] = g.device_id
|
||||
|
||||
forwarded_for = request.headers.get('X-Forwarded-For')
|
||||
client_ip = forwarded_for.split(',')[0].strip() if forwarded_for else request.remote_addr
|
||||
|
||||
device_ip_collection.insert_one({
|
||||
"microwave_id": g.device_id,
|
||||
"client_ip": client_ip,
|
||||
"received_at": datetime.now(timezone.utc)
|
||||
})
|
||||
|
||||
height_cm = float(data.get("dish_height", 4.0))
|
||||
initial_temp_c = float(data.get("ir_initial_temp", 20.0))
|
||||
microwave_wattage = int(data.get("microwave_wattage", 900))
|
||||
defrost_mode = bool(data.get("defrost_mode", False))
|
||||
|
||||
camera_image_b64 = data.get("camera_image")
|
||||
if not camera_image_b64:
|
||||
return jsonify({"@odata.error": {"code": "400", "message": "Missing camera_image"}}), 400
|
||||
|
||||
# 1. Handle the Camera Image
|
||||
camera_image_b64 = data.get("camera_image")
|
||||
filepath = None
|
||||
|
||||
if camera_image_b64:
|
||||
filename = f"dish_{uuid.uuid4().hex}.jpg"
|
||||
filepath = os.path.join(CAMERA_IMAGE_DIR, filename)
|
||||
|
||||
try:
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(base64.b64decode(camera_image_b64))
|
||||
|
||||
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
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(base64.b64decode(camera_image_b64))
|
||||
data["camera_image"] = filepath
|
||||
|
||||
enhanced_filepath = enhance_image_for_ai(filepath)
|
||||
|
||||
# 2. Run the Cook Planning Engine
|
||||
try:
|
||||
cook_plan = microwave_cook_planner.generate_plan(
|
||||
image_path=filepath,
|
||||
# Concurrent AI Execution
|
||||
safety_task = asyncio.to_thread(safety_checker.check_dish_safety, filepath)
|
||||
planner_task = asyncio.to_thread(
|
||||
microwave_cook_planner.generate_plan,
|
||||
image_path=enhanced_filepath,
|
||||
height_cm=height_cm,
|
||||
initial_temp_c=initial_temp_c,
|
||||
microwave_wattage=microwave_wattage,
|
||||
defrost_mode=defrost_mode
|
||||
)
|
||||
|
||||
safety_result, cook_plan = await asyncio.gather(safety_task, planner_task)
|
||||
|
||||
data["safety_check"] = safety_result
|
||||
data["analysis_results"] = cook_plan
|
||||
|
||||
inserted = cooking_collection.insert_one(data)
|
||||
doc_id = str(inserted.inserted_id)
|
||||
|
||||
# Base OData v4 response wrapper
|
||||
odata_response = {
|
||||
"@odata.context": f"{request.host_url.rstrip('/')}/$metadata#CookingParams/$entity",
|
||||
"@odata.id": f"{request.host_url.rstrip('/')}/cooking-params('{doc_id}')",
|
||||
"id": doc_id,
|
||||
"plan": cook_plan
|
||||
}
|
||||
|
||||
if not safety_result.get("is_safe", True):
|
||||
odata_response.update({
|
||||
"error": "Unsafe cooking area",
|
||||
"is_safe": False,
|
||||
"warning_message": safety_result.get("warning_message", "Unsafe materials detected."),
|
||||
"detected_hazards": safety_result.get("detected_hazards", [])
|
||||
})
|
||||
return jsonify(odata_response), 200
|
||||
|
||||
return jsonify(odata_response), 201
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({"error": f"Failed to compute cooking plan: {str(e)}"}), 500
|
||||
current_app.logger.exception("Error in /cooking-params")
|
||||
return jsonify({"@odata.error": {"code": "500", "message": "Internal processing error"}}), 500
|
||||
|
||||
# 3. Attach cooking parameters to database record
|
||||
data["analysis_results"] = cook_plan
|
||||
|
||||
# 4. Save to MongoDB
|
||||
try:
|
||||
cooking_collection.insert_one(data)
|
||||
data.pop("_id", None)
|
||||
except Exception as e:
|
||||
return jsonify({"error": f"Database error: {str(e)}"}), 500
|
||||
|
||||
# 5. Return complete output
|
||||
return jsonify(cook_plan), 201
|
||||
|
||||
@app.route("/device-network", methods=["POST"])
|
||||
def device_network():
|
||||
@app.route("/alert", methods=["POST"])
|
||||
def alert():
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return jsonify({"error": "Invalid or missing JSON payload"}), 400
|
||||
if data is None or not isinstance(data, dict):
|
||||
return jsonify({
|
||||
"@odata.error": {
|
||||
"code": "400",
|
||||
"message": "Invalid or missing JSON payload"
|
||||
}
|
||||
}), 400
|
||||
|
||||
# Auto-populate or override orchestrator_id using authenticated device context
|
||||
orchestrator_id = data.get("orchestrator_id") or getattr(g, "device_id", None)
|
||||
data["orchestrator_id"] = orchestrator_id
|
||||
data["created_at"] = datetime.now(timezone.utc)
|
||||
|
||||
# 2. Save to MongoDB
|
||||
try:
|
||||
# Insert the dictionary directly into Mongo (it will retain your exact JSON keys)
|
||||
device_network_collection.insert_one(data)
|
||||
|
||||
return "", 200
|
||||
|
||||
saved_alert = alert_collection.insert_one(data)
|
||||
doc_id = str(saved_alert.inserted_id)
|
||||
except Exception as e:
|
||||
return jsonify({"error": f"Database error: {str(e)}"}), 500
|
||||
current_app.logger.exception("Failed to insert alert into database")
|
||||
return jsonify({
|
||||
"@odata.error": {
|
||||
"code": "500",
|
||||
"message": "Database write failed"
|
||||
}
|
||||
}), 500
|
||||
|
||||
alert_info = data.get("alert", {})
|
||||
alert_type = alert_info.get("type", "Unknown")
|
||||
alert_message = alert_info.get("message", "No message provided")
|
||||
|
||||
webex_room_id = None
|
||||
room_link = None
|
||||
room_title = None
|
||||
webex_status = "skipped"
|
||||
sms_sent = False
|
||||
|
||||
@app.route("/debug", methods=["GET"])
|
||||
def debug():
|
||||
image_path = "microwaveDish.jpg"
|
||||
edamam = EdamamAPI()
|
||||
return edamam.analyze_dish_image(image_path)
|
||||
if alert_type != "COOKING_SAFETY":
|
||||
|
||||
client_name = f"Unknown Client (Orchestrator {orchestrator_id})" if orchestrator_id else "Unknown Client"
|
||||
client_email = None
|
||||
client_phone = None
|
||||
|
||||
if orchestrator_id:
|
||||
client_doc = client_collection.find_one({
|
||||
"devices": {
|
||||
"$elemMatch": {
|
||||
"device_type": "orchestrator",
|
||||
"device_id": str(orchestrator_id)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if client_doc:
|
||||
client_name = client_doc.get("client_name", client_name)
|
||||
contact_info = client_doc.get("contact_info", {})
|
||||
client_email = contact_info.get("email")
|
||||
client_phone = contact_info.get("phone")
|
||||
|
||||
room_title = f"Support - {client_name}"
|
||||
|
||||
three_minutes_ago = datetime.now(timezone.utc) - timedelta(minutes=3)
|
||||
|
||||
recent_alert = alert_collection.find_one({
|
||||
"orchestrator_id": orchestrator_id,
|
||||
"webex_room_id": {"$exists": True, "$ne": None},
|
||||
"created_at": {"$gte": three_minutes_ago},
|
||||
"_id": {"$ne": saved_alert.inserted_id}
|
||||
}, sort=[("created_at", DESCENDING)])
|
||||
|
||||
if recent_alert:
|
||||
webex_room_id = recent_alert["webex_room_id"]
|
||||
room_title = recent_alert.get("webex_room_title", room_title)
|
||||
try:
|
||||
webex_manager.send_alert_info_message(
|
||||
room_id=webex_room_id,
|
||||
alert_type=alert_type,
|
||||
alert_message=alert_message,
|
||||
alert_id=doc_id,
|
||||
added_alert_info=True
|
||||
)
|
||||
webex_status = "reused_room"
|
||||
room_link = recent_alert.get("webex_room_link") or f"https://web.webex.com/spaces/{webex_room_id}"
|
||||
except Exception as e:
|
||||
current_app.logger.exception("Failed to send follow-up message to Webex room")
|
||||
webex_status = f"message_failed: {str(e)}"
|
||||
else:
|
||||
try:
|
||||
room_details = webex_manager.create_support_room(
|
||||
client_name,
|
||||
client_email,
|
||||
alert_type,
|
||||
alert_message,
|
||||
doc_id
|
||||
)
|
||||
|
||||
if isinstance(room_details, dict):
|
||||
webex_room_id = room_details.get("id")
|
||||
room_title = room_details.get("title", room_title)
|
||||
room_link = room_details.get("meetingLink") or f"https://web.webex.com/spaces/{webex_room_id}"
|
||||
else:
|
||||
webex_room_id = room_details
|
||||
room_link = f"https://web.webex.com/spaces/{webex_room_id}"
|
||||
|
||||
webex_status = "created_new_room"
|
||||
except Exception as e:
|
||||
current_app.logger.exception("Failed to create Webex support room")
|
||||
webex_status = f"creation_failed: {str(e)}"
|
||||
|
||||
if webex_room_id and client_phone and not SAVEUP_TWILIO_API_TOKEN:
|
||||
sms_sent = send_alert_sms(
|
||||
to_phone=client_phone,
|
||||
room_title=room_title,
|
||||
room_link=room_link,
|
||||
client_email=client_email
|
||||
)
|
||||
|
||||
update_fields = {"sms_sent": sms_sent}
|
||||
if webex_room_id:
|
||||
update_fields.update({
|
||||
"webex_room_id": webex_room_id,
|
||||
"webex_room_title": room_title,
|
||||
"webex_room_link": room_link
|
||||
})
|
||||
|
||||
alert_collection.update_one({"_id": saved_alert.inserted_id}, {"$set": update_fields})
|
||||
|
||||
# OData v4 Created Response Payload
|
||||
return jsonify({
|
||||
"@odata.context": f"{request.host_url.rstrip('/')}/$metadata#Alerts/$entity",
|
||||
"@odata.id": f"{request.host_url.rstrip('/')}/alert('{doc_id}')",
|
||||
"id": doc_id,
|
||||
"status": "success",
|
||||
"message": "Alert saved",
|
||||
"orchestrator_id": orchestrator_id,
|
||||
"webex_room_id": webex_room_id,
|
||||
"webex_room_url": room_link,
|
||||
"webex_status": webex_status,
|
||||
"sms_sent": sms_sent
|
||||
}), 201
|
||||
|
||||
@app.route("/telemetry", methods=["POST"])
|
||||
def telemetry():
|
||||
data = request.get_json()
|
||||
if data is None:
|
||||
return jsonify({"@odata.error": {"code": "400", "message": "Invalid JSON payload"}}), 400
|
||||
|
||||
if isinstance(data, str):
|
||||
try:
|
||||
data = json.loads(data)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return jsonify({"@odata.error": {"code": "400", "message": "String payload invalid"}}), 400
|
||||
|
||||
data["device_id"] = g.device_id
|
||||
data["received_at"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
try:
|
||||
inserted = telemetry_collection.insert_one(data)
|
||||
doc_id = str(inserted.inserted_id)
|
||||
|
||||
# OData v4 Created Response Envelope
|
||||
return jsonify({
|
||||
"@odata.context": f"{request.host_url.rstrip('/')}/$metadata#Telemetry/$entity",
|
||||
"@odata.id": f"{request.host_url.rstrip('/')}/telemetry('{doc_id}')",
|
||||
"id": doc_id,
|
||||
"status": "success",
|
||||
"message": "Telemetry saved",
|
||||
"device_id": g.device_id,
|
||||
"received_at": data["received_at"]
|
||||
}), 201
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.exception("Database write failed")
|
||||
return jsonify({"@odata.error": {"code": "500", "message": "Database write failed"}}), 500
|
||||
|
||||
|
||||
@app.route('/api/security/cve-audit', methods=['POST', 'GET'])
|
||||
def run_cve_audit():
|
||||
"""Protected endpoint triggered by Node-RED using REST API + API Key."""
|
||||
# Only node-red is allowed in this endpoint
|
||||
if g.device_id != "node-red":
|
||||
return jsonify({
|
||||
"@odata.error": {
|
||||
"code": "403",
|
||||
"message": "Forbidden: Not allowed to trigger CVE audit"
|
||||
}
|
||||
}), 403
|
||||
|
||||
audit_results = shodan_auditor.audit_server_vulnerabilities()
|
||||
|
||||
inserted = cve_audit_results.insert_one({
|
||||
"triggered_by": g.device_id,
|
||||
"audit_results": audit_results,
|
||||
"timestamp": datetime.now(timezone.utc)
|
||||
})
|
||||
doc_id = str(inserted.inserted_id)
|
||||
|
||||
status_code = 200 if audit_results.get("status") == "PASS" else 409
|
||||
|
||||
return jsonify({
|
||||
"@odata.context": f"{request.host_url.rstrip('/')}/$metadata#CveAudit/$entity",
|
||||
"@odata.id": f"{request.host_url.rstrip('/')}/api/security/cve-audit('{doc_id}')",
|
||||
"id": doc_id,
|
||||
"status": audit_results.get("status"),
|
||||
"audit_results": audit_results,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat()
|
||||
}), status_code
|
||||
|
||||
@app.route("/rfid-card", methods=["GET"])
|
||||
def get_rfid_cards():
|
||||
"""OData compliant endpoint for technician RFID card validation."""
|
||||
filter_str = request.args.get("$filter", "")
|
||||
card_id = request.args.get("card_id")
|
||||
|
||||
mongo_query = {}
|
||||
|
||||
# Extract card_id from OData $filter string if not supplied directly
|
||||
if not card_id and filter_str:
|
||||
match = re.search(r"(?:card_id|cardId)\s+eq\s+['\"]([^'\"]+)['\"]", filter_str, re.IGNORECASE)
|
||||
if match:
|
||||
card_id = match.group(1)
|
||||
else:
|
||||
# Generic fallback to extract quoted strings
|
||||
generic_match = re.search(r"['\"]([a-zA-Z0-9]+)['\"]", filter_str)
|
||||
if generic_match:
|
||||
card_id = generic_match.group(1)
|
||||
|
||||
if card_id:
|
||||
mongo_query["card_id"] = card_id
|
||||
|
||||
# Filter for active/valid cards only
|
||||
mongo_query["valid"] = True
|
||||
|
||||
try:
|
||||
cards = list(rfid_cards_collection.find(mongo_query))
|
||||
|
||||
result_value = [
|
||||
{
|
||||
"id": str(card["_id"]),
|
||||
"card_id": card.get("card_id"),
|
||||
"valid": card.get("valid", False)
|
||||
}
|
||||
for card in cards
|
||||
]
|
||||
|
||||
return jsonify({
|
||||
"@odata.context": f"{request.host_url.rstrip('/')}/$metadata#RfidCards",
|
||||
"value": result_value
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.exception("Error querying rfid_cards collection")
|
||||
return jsonify({"@odata.error": {"code": "500", "message": "Database query error"}}), 500
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Debug Endpoints (Protected)
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@app.route("/debug/telemetryrequest", methods=["GET"])
|
||||
def debug_telemetryrequest():
|
||||
# Construct external HTTP endpoint dynamically based on incoming request host
|
||||
telemetry_url = f"{request.host_url.rstrip('/').replace('http://', 'https://')}/telemetry"
|
||||
|
||||
cmd_payload = {
|
||||
"action": "request_telemetry",
|
||||
"endpoint": telemetry_url
|
||||
}
|
||||
|
||||
try:
|
||||
send_command(topic="cmd/all", payload=cmd_payload)
|
||||
return jsonify({
|
||||
"status": "Telemetry command sent to cmd/all",
|
||||
"published_payload": cmd_payload
|
||||
}), 200
|
||||
except Exception as e:
|
||||
return jsonify({"error": f"Failed to publish MQTT command: {str(e)}"}), 500
|
||||
|
||||
|
||||
@app.route('/debug/run-job/<job_id>', methods=['POST', 'GET'])
|
||||
def trigger_job_manually(job_id):
|
||||
"""Manually triggers any scheduled job immediately by its ID."""
|
||||
job = scheduler.get_job(job_id)
|
||||
if not job:
|
||||
return jsonify({
|
||||
"error": f"Job '{job_id}' not found",
|
||||
"available_jobs": [j.id for j in scheduler.get_jobs()]
|
||||
}), 404
|
||||
|
||||
try:
|
||||
job.func(*job.args)
|
||||
return jsonify({
|
||||
"status": "success",
|
||||
"message": f"Job '{job_id}' executed successfully."
|
||||
}), 200
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"message": f"Job execution failed: {str(e)}"
|
||||
}), 500
|
||||
|
||||
@app.route("/debug/dishsafety/not_safe", methods=["GET"])
|
||||
def debug_unsafe_dish():
|
||||
"""Debug endpoint to simulate an unsafe dish scenario."""
|
||||
# Simulated unsafe dish data
|
||||
unsafe_dish = "storage/dishPhotos/dish_78827473286c4f67855f3fbd0ccb9bb4.jpg"
|
||||
|
||||
# Call the cooking_params endpoint logic directly
|
||||
return safety_checker.check_dish_safety(unsafe_dish)
|
||||
|
||||
@app.route("/debug/dishsafety/safe", methods=["GET"])
|
||||
def debug_safe_dish():
|
||||
"""Debug endpoint to simulate a safe dish scenario."""
|
||||
# Simulated safe dish data
|
||||
unsafe_dish = "storage/dishPhotos/dish_af60c26a08a74ce894d453394f417c22.jpg"
|
||||
|
||||
# Call the cooking_params endpoint logic directly
|
||||
return safety_checker.check_dish_safety(unsafe_dish)
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# OData Metadata Definition
|
||||
# ---------------------------------------------------------
|
||||
@app.route("/$metadata", methods=["GET"])
|
||||
def odata_metadata():
|
||||
"""Provides mandatory OData schema definition for validation."""
|
||||
xml_metadata = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx">
|
||||
<edmx:DataServices>
|
||||
<Schema Namespace="MicrowaveNetwork" xmlns="http://docs.oasis-open.org/odata/ns/edm">
|
||||
<EntityType Name="Telemetry">
|
||||
<Key><PropertyRef Name="id"/></Key>
|
||||
<Property Name="id" Type="Edm.String" Nullable="false"/>
|
||||
<Property Name="device_id" Type="Edm.String"/>
|
||||
<Property Name="received_at" Type="Edm.DateTimeOffset"/>
|
||||
</EntityType>
|
||||
<EntityType Name="CookingParams">
|
||||
<Key><PropertyRef Name="id"/></Key>
|
||||
<Property Name="id" Type="Edm.String" Nullable="false"/>
|
||||
<Property Name="microwave_id" Type="Edm.String"/>
|
||||
</EntityType>
|
||||
<EntityType Name="CveAudit">
|
||||
<Key><PropertyRef Name="id"/></Key>
|
||||
<Property Name="id" Type="Edm.String" Nullable="false"/>
|
||||
<Property Name="status" Type="Edm.String"/>
|
||||
</EntityType>
|
||||
<EntityType Name="Alert">
|
||||
<Key><PropertyRef Name="id"/></Key>
|
||||
<Property Name="id" Type="Edm.String" Nullable="false"/>
|
||||
<Property Name="orchestrator_id" Type="Edm.String"/>
|
||||
<Property Name="webex_room_id" Type="Edm.String"/>
|
||||
<Property Name="webex_status" Type="Edm.String"/>
|
||||
<Property Name="sms_sent" Type="Edm.Boolean"/>
|
||||
</EntityType>
|
||||
<EntityType Name="RfidCard">
|
||||
<Key><PropertyRef Name="id"/></Key>
|
||||
<Property Name="id" Type="Edm.String" Nullable="false"/>
|
||||
<Property Name="card_id" Type="Edm.String" Nullable="false"/>
|
||||
<Property Name="valid" Type="Edm.Boolean" Nullable="false"/>
|
||||
</EntityType>
|
||||
<EntityContainer Name="Container">
|
||||
<EntitySet Name="Telemetry" EntityType="MicrowaveNetwork.Telemetry"/>
|
||||
<EntitySet Name="CookingParams" EntityType="MicrowaveNetwork.CookingParams"/>
|
||||
<EntitySet Name="CveAudit" EntityType="MicrowaveNetwork.CveAudit"/>
|
||||
<EntitySet Name="Alerts" EntityType="MicrowaveNetwork.Alert"/>
|
||||
<EntitySet Name="RfidCards" EntityType="MicrowaveNetwork.RfidCard"/>
|
||||
</EntityContainer>
|
||||
</Schema>
|
||||
</edmx:DataServices>
|
||||
</edmx:Edmx>"""
|
||||
return Response(xml_metadata, mimetype="application/xml")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=config.DEBUG)
|
||||
app.run(debug=getattr(config, "DEBUG", False))
|
||||
@@ -0,0 +1,26 @@
|
||||
import cv2
|
||||
|
||||
def enhance_image_for_ai(input_path: str) -> str:
|
||||
"""Smooths camera sensor noise and brightens dark areas for vision LLMs."""
|
||||
img = cv2.imread(input_path)
|
||||
if img is None:
|
||||
return input_path
|
||||
|
||||
# 1. Remove ISO sensor noise (grain)
|
||||
denoised = cv2.fastNlMeansDenoisingColored(img, None, h=7, hColor=7, templateWindowSize=7, searchWindowSize=21)
|
||||
|
||||
# 2. Convert to LAB color space for luminance enhancement
|
||||
lab = cv2.cvtColor(denoised, cv2.COLOR_BGR2LAB)
|
||||
l, a, b = cv2.split(lab)
|
||||
|
||||
# 3. Apply softer CLAHE (lower clipLimit prevents noise amplification)
|
||||
clahe = cv2.createCLAHE(clipLimit=1.8, tileGridSize=(8, 8))
|
||||
cl = clahe.apply(l)
|
||||
|
||||
# 4. Merge channels back
|
||||
limg = cv2.merge((cl, a, b))
|
||||
enhanced = cv2.cvtColor(limg, cv2.COLOR_LAB2BGR)
|
||||
|
||||
enhanced_path = input_path.replace(".jpg", "_enhanced.jpg")
|
||||
cv2.imwrite(enhanced_path, enhanced, [cv2.IMWRITE_JPEG_QUALITY, 90])
|
||||
return enhanced_path
|
||||
@@ -0,0 +1,84 @@
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
# Fallback base URL since 'request.host_url' is not available in background threads
|
||||
SERVER_BASE_URL = os.getenv("SERVER_BASE_URL", "https://smartwave.matthiasg.dev")
|
||||
|
||||
|
||||
def job_server_cve_audit(shodan_auditor, db):
|
||||
"""Job 1: Audits Flask server public IP for CVEs and saves to MongoDB."""
|
||||
print("[Cron] Starting Server CVE Audit...")
|
||||
|
||||
audit_results = shodan_auditor.audit_server_vulnerabilities()
|
||||
|
||||
db.cve_audit_results.insert_one({
|
||||
"audit_results": audit_results,
|
||||
"timestamp": datetime.now(timezone.utc)
|
||||
})
|
||||
|
||||
print(f"[Cron] Server CVE Audit finished. Status: {audit_results.get('status')}")
|
||||
|
||||
|
||||
def job_client_ip_audit(shodan_auditor, db):
|
||||
"""
|
||||
Job 2: Fetches distinct client IPs logged in the last 24h and audits them.
|
||||
Deduplicates IPs and applies rate limits to protect Shodan API credits.
|
||||
"""
|
||||
print("[Cron] Starting Client IP Security Audit...")
|
||||
|
||||
pipeline = [
|
||||
{"$group": {
|
||||
"_id": "$client_ip",
|
||||
"microwave_ids": {"$addToSet": "$microwave_id"},
|
||||
"last_seen": {"$max": "$received_at"}
|
||||
}}
|
||||
]
|
||||
|
||||
recent_clients = list(db.device_ips.aggregate(pipeline))
|
||||
|
||||
for entry in recent_clients:
|
||||
client_ip = entry["_id"]
|
||||
|
||||
# Skip local/private IPs
|
||||
if not client_ip or client_ip in ["127.0.0.1", "localhost"] or client_ip.startswith(("10.", "192.168.", "172.16.")):
|
||||
continue
|
||||
|
||||
profile = shodan_auditor.profile_client_ip(client_ip)
|
||||
|
||||
# Upsert client profile in a separate collection
|
||||
db.client_audit_results.update_one(
|
||||
{"client_ip": client_ip},
|
||||
{
|
||||
"$set": {
|
||||
"client_ip": client_ip,
|
||||
"microwave_ids": entry["microwave_ids"],
|
||||
"last_seen": entry["last_seen"],
|
||||
"profile": profile,
|
||||
"audited_at": datetime.now(timezone.utc)
|
||||
}
|
||||
},
|
||||
upsert=True
|
||||
)
|
||||
|
||||
# Pause 1 second to respect Shodan API rate limits (1 req/sec)
|
||||
time.sleep(1)
|
||||
|
||||
print(f"[Cron] Client IP Security Audit completed for {len(recent_clients)} IPs.")
|
||||
|
||||
|
||||
def job_request_telemetry(send_command_func):
|
||||
"""Job 3: Triggers MQTT command to request telemetry from all microwaves."""
|
||||
print("[Cron] Sending MQTT Telemetry Request to cmd/all...")
|
||||
|
||||
telemetry_url = f"{SERVER_BASE_URL.rstrip('/')}/telemetry"
|
||||
cmd_payload = {
|
||||
"action": "request_telemetry",
|
||||
"endpoint": telemetry_url
|
||||
}
|
||||
|
||||
try:
|
||||
send_command_func(topic="cmd/all", payload=cmd_payload)
|
||||
print("[Cron] Telemetry request successfully published.")
|
||||
except Exception as e:
|
||||
print(f"[Cron Error] Failed to publish MQTT command: {str(e)}")
|
||||
@@ -96,5 +96,6 @@ class MicrowaveCookPlanner:
|
||||
"reconciled_mass_g": final_mass_g,
|
||||
"mass_validation_status": mass_data["status"],
|
||||
"cook_plan": cook_plan,
|
||||
"geometry": vol_data
|
||||
"geometry": vol_data,
|
||||
"edaman_response": edamam_resp
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import cv2
|
||||
import numpy as np
|
||||
from typing import Dict, Any, Optional
|
||||
import sys
|
||||
sys.path.insert(0, '..')
|
||||
try:
|
||||
from shared import config
|
||||
except ImportError:
|
||||
from ..shared import config
|
||||
|
||||
|
||||
class MicrowaveDishAnalyzer:
|
||||
@@ -10,8 +16,7 @@ class MicrowaveDishAnalyzer:
|
||||
"""
|
||||
|
||||
# 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
|
||||
CM_PER_PIXEL: float = 0.005925926
|
||||
|
||||
def __init__(self, cm_per_pixel: Optional[float] = None):
|
||||
if cm_per_pixel is not None:
|
||||
@@ -43,6 +48,17 @@ class MicrowaveDishAnalyzer:
|
||||
largest_contour = max(contours, key=cv2.contourArea)
|
||||
area_pixels = cv2.contourArea(largest_contour)
|
||||
|
||||
# DEBUG CONDITION: Draw and save contour image if DEBUG_DISHANALYZER is set
|
||||
if getattr(config, "DEBUG_DISHANALYZER", False):
|
||||
annotated_image = image.copy()
|
||||
# Draw the contour in red (BGR: 0, 0, 255) with thickness of 2
|
||||
cv2.drawContours(annotated_image, [largest_contour], -1, (0, 0, 255), 2)
|
||||
|
||||
# Handle file extensions gracefully
|
||||
analyzed_path = image_path.replace(".jpg", "_analyzed.jpg")
|
||||
|
||||
cv2.imwrite(analyzed_path, annotated_image)
|
||||
|
||||
# 5. Convert pixels² to cm² using scale ratio squared
|
||||
area_cm2 = area_pixels * (self.cm_per_pixel ** 2)
|
||||
return float(area_cm2)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
def parse_odata_query(request_args):
|
||||
"""
|
||||
Translates standard OData query parameters ($top, $skip, $orderby, $filter)
|
||||
into MongoDB query parameters.
|
||||
"""
|
||||
limit = int(request_args.get('$top', 50))
|
||||
skip = int(request_args.get('$skip', 0))
|
||||
|
||||
# Parse $orderby (e.g., $orderby=received_at desc)
|
||||
sort = []
|
||||
orderby_str = request_args.get('$orderby')
|
||||
if orderby_str:
|
||||
parts = orderby_str.split()
|
||||
field = parts[0]
|
||||
direction = -1 if len(parts) > 1 and parts[1].lower() == 'desc' else 1
|
||||
sort.append((field, direction))
|
||||
|
||||
# Parse basic $filter (e.g., $filter=device_id eq '1')
|
||||
mongo_filter = {}
|
||||
filter_str = request_args.get('$filter')
|
||||
if filter_str and ' eq ' in filter_str:
|
||||
field, val = filter_str.split(' eq ')
|
||||
field = field.strip()
|
||||
val = val.strip().strip("'").strip('"')
|
||||
|
||||
# Cast data types
|
||||
if val.isdigit():
|
||||
val = int(val)
|
||||
elif val.lower() == 'true':
|
||||
val = True
|
||||
elif val.lower() == 'false':
|
||||
val = False
|
||||
|
||||
mongo_filter[field] = val
|
||||
|
||||
return {
|
||||
"filter": mongo_filter,
|
||||
"limit": limit,
|
||||
"skip": skip,
|
||||
"sort": sort or [("_id", -1)]
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
Flask==3.0.2
|
||||
Flask[async]==3.0.2
|
||||
pymongo==4.6.1
|
||||
gunicorn==21.2.0
|
||||
opencv-python-headless
|
||||
requests==2.32.3
|
||||
requests==2.32.3
|
||||
paho-mqtt>=1.6,<3
|
||||
openai>=1.0.0
|
||||
pydantic>=2.0.0
|
||||
twilio>=9.0.0
|
||||
APScheduler>=3.10.4,<4.0.0
|
||||
@@ -0,0 +1,52 @@
|
||||
import json
|
||||
from pydantic import BaseModel, Field
|
||||
from APIs.aichat import generate
|
||||
import shared.config as config
|
||||
|
||||
class DishSafetyResult(BaseModel):
|
||||
utensil_or_foreign_object_detected: bool = Field(
|
||||
description="Set to True if ANY spoon, fork, knife, handle, or foil is inside or touching the bowl. Set to False ONLY if the bowl contains ONLY 100% food."
|
||||
)
|
||||
is_safe: bool = Field(
|
||||
description="MUST be False if utensil_or_foreign_object_detected is True. Set to True ONLY if pure food/bowl with ZERO utensils."
|
||||
)
|
||||
detected_hazards: list[str] = Field(
|
||||
description="List any items found (e.g., ['spoon']). Empty list [] if safe."
|
||||
)
|
||||
warning_message: str = Field(
|
||||
description="Warning statement if unsafe, otherwise empty string ''."
|
||||
)
|
||||
|
||||
def check_dish_safety(image_path: str) -> dict:
|
||||
prompt = (
|
||||
"Inspect this top-down photo of a food dish inside a microwave.\n\n"
|
||||
"STRICT MICROWAVE RULES:\n"
|
||||
"1. Check if ANY utensil (spoon, fork, knife, or handle) is present in or on the bowl.\n"
|
||||
"2. If ANY spoon or utensil is present (metal, plastic, or ceramic), you MUST set `utensil_or_foreign_object_detected = True` and `is_safe = False`.\n"
|
||||
"3. `is_safe` can ONLY be True if the dish contains strictly food and nothing else."
|
||||
)
|
||||
|
||||
response_raw = generate(
|
||||
prompt=prompt,
|
||||
images=[image_path],
|
||||
output_format=DishSafetyResult,
|
||||
should_think=False,
|
||||
)
|
||||
|
||||
if config.DEBUG_MESSAGES:
|
||||
print(f"[Debug] Raw response from AI generator: {response_raw}")
|
||||
|
||||
if isinstance(response_raw, str):
|
||||
try:
|
||||
validated_result = DishSafetyResult.model_validate_json(response_raw)
|
||||
return validated_result.model_dump()
|
||||
except Exception:
|
||||
return json.loads(response_raw)
|
||||
|
||||
if isinstance(response_raw, DishSafetyResult):
|
||||
return response_raw.model_dump()
|
||||
|
||||
if isinstance(response_raw, dict):
|
||||
return response_raw
|
||||
|
||||
raise ValueError(f"Unexpected response type from AI generator: {type(response_raw)}")
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
# Configuration des ports (À remplacer par tes propres chemins by-path)
|
||||
# Pour trouver tes chemins : ls -l /dev/serial/by-path/
|
||||
PORT_ESP_WIFI="/dev/serial/by-path/pci-0000:00:14.0-usbv2-0:6.2:1.0-port0"
|
||||
PORT_ESP_LORA="/dev/serial/by-path/pci-0000:00:14.0-usbv2-0:6.1:1.0-port0"
|
||||
PORT_ESP_WIFI="/dev/serial/by-path/pci-0000:00:14.0-usb-0:2.1:1.0-port0"
|
||||
PORT_ESP_LORA="/dev/serial/by-path/pci-0000:00:14.0-usb-0:2.2:1.0-port0"
|
||||
# Ajoute les autres si besoin...
|
||||
|
||||
# Configuration Raspberry Pi
|
||||
@@ -65,6 +65,7 @@ deploy_to_rpi() {
|
||||
--exclude '__pycache__' \
|
||||
--exclude '*.pyc' \
|
||||
--exclude '.mypy_cache' \
|
||||
--exclude 'ecran_externe' \
|
||||
./ "${RPI_USER}@${RPI_HOST}:${RPI_DEST}/"
|
||||
|
||||
echo "🔄 Redémarrage du service systemd ${RPI_SYSTEMD_SERVICE}..."
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Gitignore settings for ESPHome
|
||||
# This is an example and may include too much for your use-case.
|
||||
# You can modify this file to suit your needs.
|
||||
/.esphome/
|
||||
/secrets.yaml
|
||||
@@ -0,0 +1,19 @@
|
||||
`esphome run epaper_thread_node.yaml`
|
||||
|
||||
`esphome logs epaper_thread_node.yaml`
|
||||
|
||||
# Config OTBR
|
||||
|
||||
## 1. Stop the active Thread interface
|
||||
docker exec -it otbr ot-ctl thread stop
|
||||
docker exec -it otbr ot-ctl ifconfig down
|
||||
|
||||
## 2. Inject your ESP32-H2 Active Dataset TLV
|
||||
docker exec -it otbr ot-ctl dataset set active 00030000184a0300000b35060004001fffe00208f7754c0c4a4dea9a0708fd1adbbe7435e95405103f543281275a135e38982974aa71e987030f4f70656e5468726561642d3766343101027f410410a500c86abfe7ef17888c2232feac2dc60c0402a0f7f80e080000000000010000
|
||||
|
||||
## 3. Commit the dataset as active
|
||||
docker exec -it otbr ot-ctl dataset commit active
|
||||
|
||||
## 4. Bring up the interface and start Thread
|
||||
docker exec -it otbr ot-ctl ifconfig up
|
||||
docker exec -it otbr ot-ctl thread start
|
||||
@@ -0,0 +1 @@
|
||||
3
|
||||
@@ -0,0 +1,86 @@
|
||||
#pragma once
|
||||
#include "esphome.h"
|
||||
#include "esphome/components/web_server_base/web_server_base.h"
|
||||
#include "esphome/components/json/json_util.h"
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
struct MicrowaveData {
|
||||
std::string id;
|
||||
std::string name;
|
||||
int state;
|
||||
bool paused;
|
||||
int remaining_time;
|
||||
int progress;
|
||||
};
|
||||
|
||||
// Global states parsed from the API
|
||||
static std::vector<MicrowaveData> g_microwaves;
|
||||
static bool g_cloud_alert = true;
|
||||
static std::string g_update_time = "--:--";
|
||||
static bool g_received_update = false;
|
||||
|
||||
// Callback signature for refreshing UI
|
||||
using DisplayUpdateCallback = std::function<void()>;
|
||||
static DisplayUpdateCallback g_display_callback = nullptr;
|
||||
|
||||
class DisplayApiHandler : public esphome::web_server_idf::AsyncWebHandler {
|
||||
public:
|
||||
bool canHandle(esphome::web_server_idf::AsyncWebServerRequest *request) const override {
|
||||
// Provide the 513-byte buffer expected by ESPHome's new memory-safe API
|
||||
char url_buf[513];
|
||||
return request->url_to(url_buf) == "/api/display";
|
||||
}
|
||||
|
||||
void handleRequest(esphome::web_server_idf::AsyncWebServerRequest *request) override {
|
||||
request->send(200, "application/json", "{\"status\":\"ok\"}");
|
||||
}
|
||||
|
||||
void handleBody(esphome::web_server_idf::AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) override {
|
||||
std::string body((char *)data, len);
|
||||
|
||||
esphome::json::parse_json(body, [](JsonObject root) -> bool {
|
||||
g_cloud_alert = root["cloud_alert"] | false;
|
||||
g_update_time = root["update_time"] | "--:--";
|
||||
|
||||
g_microwaves.clear();
|
||||
JsonArray mw_array = root["microwaves"];
|
||||
|
||||
for (JsonObject mw : mw_array) {
|
||||
MicrowaveData md;
|
||||
md.id = mw["id"] | "unknown";
|
||||
md.name = mw["name"] | "Microwave";
|
||||
md.state = mw["state"] | 4;
|
||||
md.paused = mw["paused"] | false;
|
||||
md.remaining_time = mw["remaining_time"] | 0;
|
||||
md.progress = mw["progress"] | 0;
|
||||
|
||||
g_microwaves.push_back(md);
|
||||
}
|
||||
|
||||
g_received_update = true;
|
||||
|
||||
ESP_LOGI("custom_api", "Received update. Cloud Alert: %d, Microwaves: %d",
|
||||
g_cloud_alert, g_microwaves.size());
|
||||
|
||||
if (g_display_callback != nullptr) {
|
||||
g_display_callback();
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
request->send(200, "application/json", "{\"status\":\"ok\"}");
|
||||
}
|
||||
};
|
||||
|
||||
inline void setup_display_api(DisplayUpdateCallback callback) {
|
||||
g_display_callback = callback;
|
||||
if (esphome::web_server_base::global_web_server_base != nullptr) {
|
||||
esphome::web_server_base::global_web_server_base->add_handler(new DisplayApiHandler());
|
||||
ESP_LOGI("custom_api", "Successfully registered /api/display POST handler");
|
||||
} else {
|
||||
ESP_LOGE("custom_api", "Failed to register endpoint: global_web_server_base is null");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
substitutions:
|
||||
device_id: !include device_id.txt
|
||||
|
||||
esphome:
|
||||
name: "smartwave-epaper-${device_id}"
|
||||
friendly_name: "SmartWave ePaper ExternalScreen ${device_id}"
|
||||
includes:
|
||||
- display_api.h
|
||||
on_boot:
|
||||
priority: -100.0
|
||||
then:
|
||||
- lambda: |-
|
||||
setup_display_api([]() {
|
||||
// Force immediate e-Paper refresh!
|
||||
id(my_display).update();
|
||||
});
|
||||
|
||||
web_server:
|
||||
port: 5000
|
||||
|
||||
mdns:
|
||||
disabled: false
|
||||
services:
|
||||
- service: "_displaytcp"
|
||||
protocol: "_tcp"
|
||||
port: 5000
|
||||
txt:
|
||||
version: "1.0"
|
||||
type: "epaper_2.13"
|
||||
|
||||
esp32:
|
||||
variant: esp32h2
|
||||
framework:
|
||||
type: esp-idf
|
||||
log_level: INFO
|
||||
advanced:
|
||||
loop_task_stack_size: 32768
|
||||
sdkconfig_options:
|
||||
CONFIG_ESP_MAIN_TASK_STACK_SIZE: "16384"
|
||||
CONFIG_OPENTHREAD_TASK_STACK_SIZE: "8192"
|
||||
CONFIG_ESP_WIFI_ENABLED: "n"
|
||||
CONFIG_OPENTHREAD_ENABLED: "y"
|
||||
CONFIG_MBEDTLS_DYNAMIC_BUFFER: "y"
|
||||
CONFIG_MBEDTLS_DYNAMIC_FREE_CONFIG_DATA: "y"
|
||||
|
||||
logger:
|
||||
level: VERBOSE
|
||||
hardware_uart: UART0
|
||||
logs:
|
||||
openthread: INFO
|
||||
|
||||
network:
|
||||
enable_ipv6: true
|
||||
|
||||
openthread:
|
||||
force_dataset: true
|
||||
tlv: 00030000184a0300000b35060004001fffe00208f7754c0c4a4dea9a0708fd1adbbe7435e95405103f543281275a135e38982974aa71e987030f4f70656e5468726561642d3766343101027f410410a500c86abfe7ef17888c2232feac2dc60c0402a0f7f80e080000000000010000
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# MQTT Connection over Thread IPv6
|
||||
# -------------------------------------------------------------------
|
||||
# mqtt:
|
||||
# broker: "mqtt.thread.smartwave.matthiasg.dev"
|
||||
# # broker: "mqtt://[fd36:5102:8d0:1:f1cd:3499:e546:dbec]"
|
||||
# # port: 1883
|
||||
# topic_prefix: "microwave/display"
|
||||
# keepalive: 60s
|
||||
# on_connect:
|
||||
# - mqtt.publish:
|
||||
# topic: smartwave/hello
|
||||
# payload: !lambda |-
|
||||
# return std::string("{\"id_microwave\":\"") + "${device_id}" + "\",\"type\":\"externalDisplay\"}";
|
||||
# on_message:
|
||||
# - topic: microwave/display/status
|
||||
# then:
|
||||
# - text_sensor.template.publish:
|
||||
# id: status_message
|
||||
# state: !lambda 'return x;'
|
||||
# - component.update: my_display
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Template Text Sensor
|
||||
# -------------------------------------------------------------------
|
||||
text_sensor:
|
||||
- platform: openthread_info
|
||||
role:
|
||||
name: "Thread Role"
|
||||
id: thread_role
|
||||
channel:
|
||||
name: "Thread Channel"
|
||||
id: thread_channel
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Hardware SPI Pin Mapping (ESP32-H2)
|
||||
# -------------------------------------------------------------------
|
||||
spi:
|
||||
clk_pin: GPIO10
|
||||
mosi_pin: GPIO11
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Fonts
|
||||
# -------------------------------------------------------------------
|
||||
font:
|
||||
- file:
|
||||
type: gfonts
|
||||
family: Open+Sans
|
||||
weight: 700
|
||||
size: 20
|
||||
id: font_header
|
||||
- file:
|
||||
type: gfonts
|
||||
family: Overpass
|
||||
weight: 600
|
||||
id: font_body
|
||||
size: 14
|
||||
- file:
|
||||
type: gfonts
|
||||
family: Overpass
|
||||
weight: 500
|
||||
id: font_small
|
||||
size: 12
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Waveshare 2.13-inch e-Paper Display (250x122 standard resolution)
|
||||
# -------------------------------------------------------------------
|
||||
display:
|
||||
- platform: waveshare_epaper
|
||||
cs_pin: GPIO4
|
||||
dc_pin: GPIO1
|
||||
reset_pin: GPIO2
|
||||
busy_pin: GPIO3
|
||||
model: 2.13inv3
|
||||
rotation: 90°
|
||||
update_interval: 30min
|
||||
id: my_display
|
||||
lambda: |-
|
||||
// --- STARTUP SCREEN ---
|
||||
if (!g_received_update) {
|
||||
int center_x = it.get_width() / 2;
|
||||
int center_y = it.get_height() / 2;
|
||||
it.printf(center_x, center_y - 10, id(font_header), TextAlign::CENTER, "SmartWave");
|
||||
it.printf(center_x, center_y + 15, id(font_body), TextAlign::CENTER, "Waiting for updates...");
|
||||
return;
|
||||
}
|
||||
|
||||
// --- MICROWAVE LAYOUT ---
|
||||
int y_offset = 0;
|
||||
for (const auto& mw : g_microwaves) {
|
||||
// Name
|
||||
it.print(0, y_offset, id(font_header), mw.name.c_str());
|
||||
|
||||
// Decode State Text
|
||||
std::string state_str;
|
||||
switch(mw.state) {
|
||||
case 0: state_str = mw.paused ? "PAUSED" : "COOKING"; break;
|
||||
case 1: state_str = "STIRRING REQ."; break;
|
||||
case 2: state_str = "DONE"; break;
|
||||
case 3: state_str = "ALERT"; break;
|
||||
case 4: state_str = "IDLE"; break;
|
||||
default: state_str = "UNKNOWN"; break;
|
||||
}
|
||||
|
||||
// Print Status (Top Right Align)
|
||||
it.printf(it.get_width(), y_offset + 5, id(font_body), TextAlign::TOP_RIGHT, "%s", state_str.c_str());
|
||||
|
||||
// Process active/cooking visualisations
|
||||
if (mw.state == 0) {
|
||||
int bar_y = y_offset + 28;
|
||||
int bar_w = 175;
|
||||
int bar_h = 16;
|
||||
|
||||
// Draw progress bar outline & fill
|
||||
it.rectangle(0, bar_y, bar_w, bar_h);
|
||||
if (mw.progress > 0) {
|
||||
int fill_w = (mw.progress * bar_w) / 100;
|
||||
it.filled_rectangle(0, bar_y, fill_w, bar_h);
|
||||
}
|
||||
|
||||
// Draw time remaining (or paused state)
|
||||
if (mw.paused) {
|
||||
it.printf(185, bar_y + 1, id(font_body), "PAUSED");
|
||||
} else {
|
||||
int mins = mw.remaining_time / 60;
|
||||
int secs = mw.remaining_time % 60;
|
||||
it.printf(185, bar_y + 1, id(font_body), "%02d:%02d", mins, secs);
|
||||
}
|
||||
}
|
||||
else if (mw.state == 1) { // Stirring Required
|
||||
it.filled_rectangle(0, y_offset + 28, 250, 20);
|
||||
it.printf(125, y_offset + 28, id(font_body), COLOR_OFF, TextAlign::TOP_CENTER, "ACTION REQUIRED: STIR");
|
||||
}
|
||||
|
||||
// Move cursor down (if we have more than 1 microwave on future larger screens)
|
||||
y_offset += 60;
|
||||
}
|
||||
|
||||
// --- SYSTEM FOOTER ---
|
||||
// Line separator right above footer
|
||||
it.line(0, 102, it.get_width(), 102);
|
||||
|
||||
// Bottom Left: Cloud Reachability & Last Update Time
|
||||
std::string cloud_status = g_cloud_alert ? "Cloud: ERR" : "Cloud: OK";
|
||||
it.printf(0, 105, id(font_small), "%s | %s", cloud_status.c_str(), g_update_time.c_str());
|
||||
|
||||
// Bottom Right: OpenThread Diagnostics
|
||||
std::string role = id(thread_role).state;
|
||||
it.printf(it.get_width(), 105, id(font_small), TextAlign::TOP_RIGHT,
|
||||
"Th: %s (Ch %s)", role.c_str(), id(thread_channel).state.c_str());
|
||||
@@ -0,0 +1,29 @@
|
||||
esphome:
|
||||
name: h2-test-node
|
||||
|
||||
esp32:
|
||||
board: esp32-h2-devkitm-1
|
||||
variant: esp32h2
|
||||
framework:
|
||||
type: esp-idf
|
||||
advanced:
|
||||
loop_task_stack_size: 16384
|
||||
sdkconfig_options:
|
||||
CONFIG_ESP_MAIN_TASK_STACK_SIZE: "16384"
|
||||
CONFIG_OPENTHREAD_TASK_STACK_SIZE: "8192"
|
||||
CONFIG_ESP_WIFI_ENABLED: "n"
|
||||
CONFIG_OPENTHREAD_ENABLED: "y"
|
||||
CONFIG_ESP_CONSOLE_UART_DEFAULT: "y"
|
||||
|
||||
logger:
|
||||
level: INFO
|
||||
hardware_uart: UART0
|
||||
|
||||
network:
|
||||
enable_ipv6: true
|
||||
|
||||
openthread:
|
||||
network_name: "OpenThread-7f41"
|
||||
pan_id: 0x7f41
|
||||
channel: 24
|
||||
network_key: "0x3f543281275a135e38982974aa71e987"
|
||||
@@ -0,0 +1,152 @@
|
||||
import time
|
||||
from shared.cookingState import CookingState, CookingStates
|
||||
from shared import config
|
||||
import framebuf
|
||||
from shared.microwave_state import MicrowaveState
|
||||
|
||||
# Icon and bitmaps
|
||||
image_weather_frost_bits = bytearray(b'\x01\x00\x13\x901\x18s\x9c\x09 \x05@S\x94\xfe\xfeS\x94\x05@\x09 s\x9c1\x18\x13\x90\x01\x00\x00\x00')
|
||||
image_Alert_bits = bytearray(b'\xf7\x80\xe3\x80\xeb\x80\xc9\x80\xc9\x80\x80\x80\x88\x80\x00\x00')
|
||||
image_CoolHiSmall_bits = bytearray(b'\x1f\xff\xc0?\xff\xe0\x7f\xff\xf0\xff\xdf\xf8\xffW\xf8\xfd\x8d\xf8\xf9\xdc\xf8\xfe\xdb\xf8\xf7Wx\xfb\x8e\xf8\xe0\x008\xfb\x8e\xf8\xf7Wx\xfe\xdb\xf8\xf9\xdc\xf8\xfd\x8d\xf8\xffW\xf8\xff\xdf\xf8\x7f\xff\xf0?\xff\xe0\x1f\xff\xc0')
|
||||
image_CoolLoSmall_bits = bytearray(b'\x1f\xff\xc0 \x00 @\x00\x10\x80 \x08\x80\xa8\x08\x82r\x08\x86#\x08\x81$\x08\x88\xa8\x88\x84q\x08\x9f\xff\xc8\x84q\x08\x88\xa8\x88\x81$\x08\x86#\x08\x82r\x08\x80\xa8\x08\x80 \x08@\x00\x10 \x00 \x1f\xff\xc0')
|
||||
image_music_sound_wave_bits = bytearray(b'\x00\x00\x00\x02\x00\x00\x02 \x00\x02 \x00\x02 \x00\x0a(\x00\x0a\xa8\x00*\xa8\x00\xaa\xaa\x80\x0a\xaa\x00\x0a\xa8\x00\x0a \x00\x02 \x00\x02 \x00\x02\x00\x00\x00\x00\x00')
|
||||
image_weather_temperature_bits = bytearray(b'\x1c\x00\x22\x00+\x00*\x00+\x00*\x00+\x00*\x00*\x00I\x00\x9c\x80\xae\x80\xbe\x80\x9c\x80A\x00>\x00')
|
||||
image_camera_bits = bytearray(b'\x00x\x000\x84\x00\x7f{\x00\xc0\x00\x80\x91\xc6\x80\x860\x80\x85\xd0\x80\x8a(\x80\x8a(\x80\x8a(\x80\x85\xd0\x80\x860\x80\x91\xc6\x80\xc0\x00\x80\x7f\xff\x00\x00\x00\x00')
|
||||
image_cloud_sync_bits = bytearray(b'\x00\x00\x00\x07\xc0\x00\x08 \x00\x10\x10\x000\x08\x00@\x0e\x00\x80\x01\x00\x82 \x80G \x80/\xaf\x00\x02 \x00\x02\xf8\x00\x02p\x00\x02 \x00\x00\x00\x00\x00\x00\x00')
|
||||
fb_image_weather_frost_bits = framebuf.FrameBuffer(image_weather_frost_bits, 15, 16, framebuf.MONO_HLSB)
|
||||
fb_image_CoolHiSmall_bits = framebuf.FrameBuffer(image_CoolHiSmall_bits, 21, 21, framebuf.MONO_HLSB)
|
||||
fb_image_weather_temperature_bits = framebuf.FrameBuffer(image_weather_temperature_bits, 9, 16, framebuf.MONO_HLSB)
|
||||
fb_image_Alert_bits = framebuf.FrameBuffer(image_Alert_bits, 9, 8, framebuf.MONO_HLSB)
|
||||
fb_image_music_sound_wave_bits = framebuf.FrameBuffer(image_music_sound_wave_bits, 17, 16, framebuf.MONO_HLSB)
|
||||
fb_image_CoolLoSmall_bits = framebuf.FrameBuffer(image_CoolLoSmall_bits, 21, 21, framebuf.MONO_HLSB)
|
||||
fb_image_camera_bits = framebuf.FrameBuffer(image_camera_bits, 17, 16, framebuf.MONO_HLSB)
|
||||
fb_image_cloud_sync_bits = framebuf.FrameBuffer(image_cloud_sync_bits, 17, 16, framebuf.MONO_HLSB)
|
||||
|
||||
# Constants
|
||||
CHARACTER_WIDTH = 6
|
||||
CHARACTER_GAP = 2
|
||||
CHARACTER_FULL_WIDTH = CHARACTER_WIDTH + CHARACTER_GAP
|
||||
CHARACTER_HEIGHT = 7
|
||||
|
||||
|
||||
class MicrowaveScreen:
|
||||
# Used for animation
|
||||
# lastUpdateTime = 0 # Not used right now
|
||||
|
||||
def __init__(self, display):
|
||||
self.display = display
|
||||
self.width = display.width
|
||||
self.height = display.height
|
||||
|
||||
def h_centered_text(self, text, y, color=1):
|
||||
"""Centers text horizontally
|
||||
|
||||
Args:
|
||||
text (str): _description_
|
||||
y (int): _description_
|
||||
color (int, optional): _description_. Defaults to 1.
|
||||
"""
|
||||
text = str(text)
|
||||
text_width = len(text) * CHARACTER_FULL_WIDTH
|
||||
|
||||
if config.DEBUG:
|
||||
if text_width > self.width:
|
||||
print(f"[MicrowaveScreen] Warning: Text '{text}' is too long to fit on the screen.")
|
||||
|
||||
x = (self.width - text_width) // 2
|
||||
self.display.text(text, x, y, color)
|
||||
|
||||
def flick(self):
|
||||
self.display.fill(0)
|
||||
|
||||
def show(self):
|
||||
self.display.show()
|
||||
|
||||
def bootScreen(self):
|
||||
self.display.text("SmartWave", 28, 28, 1)
|
||||
self.show()
|
||||
|
||||
def _progressBar(self, progress, x, y, width=87, height=13, color=1):
|
||||
# Box outline
|
||||
self.display.rect(x, y, width, height, color)
|
||||
|
||||
# Fill the progress bar based on the progress value (0.0 to 1.0)
|
||||
fill_width = int(progress * (width)) # Subtract 2
|
||||
|
||||
self.display.fill_rect(x, y, fill_width, height, color)
|
||||
|
||||
def update(self, cooking_state: CookingState | None, defrost_mode: bool = False, microwave_state: str | None = None):
|
||||
self.flick()
|
||||
|
||||
# self.lastUpdateTime = time.time() # Not Used right now
|
||||
|
||||
if (not microwave_state or microwave_state in (MicrowaveState.DONE, MicrowaveState.IDLE)) and (not cooking_state or cooking_state.state == CookingStates.IDLE): # Is not currently cooking
|
||||
self.h_centered_text("Ready !", 28, 1)
|
||||
else:
|
||||
# Screen center
|
||||
if cooking_state and cooking_state.state == CookingStates.STIRRING_REQUIRED:
|
||||
self._message("Pls Stir", True)
|
||||
elif cooking_state and cooking_state.state == CookingStates.ALERT:
|
||||
# nothing to do, the alert will be displayed by the alert manager
|
||||
return
|
||||
elif cooking_state and cooking_state.state == CookingStates.DONE:
|
||||
self._message("Done !", False)
|
||||
elif microwave_state and microwave_state == MicrowaveState.ANALYZING:
|
||||
self.display.blit(fb_image_camera_bits, 56, 24)
|
||||
self._secondary_message("Analyzing...", False)
|
||||
elif microwave_state and microwave_state == MicrowaveState.WAITING_FOR_CLOUD:
|
||||
self.display.blit(fb_image_cloud_sync_bits, 55, 24)
|
||||
self._secondary_message("Receiving...", False)
|
||||
elif microwave_state and microwave_state == MicrowaveState.ALERT:
|
||||
# nothing to do, the alert will be displayed by the alert manager
|
||||
pass
|
||||
elif cooking_state: # Progress bar
|
||||
elapsed_time = cooking_state.get_elapsed_time()
|
||||
estimated_progress = elapsed_time / (elapsed_time + cooking_state.estimated_remaining_time)
|
||||
self._progressBar(estimated_progress, 21, 25)
|
||||
|
||||
# Estimated remaining time
|
||||
if cooking_state and cooking_state.paused:
|
||||
self.h_centered_text("Paused", 40, 1)
|
||||
elif cooking_state and cooking_state.state != CookingStates.DONE:
|
||||
remaining_time = int(cooking_state.estimated_remaining_time)
|
||||
minutes = remaining_time // 60
|
||||
seconds = remaining_time % 60
|
||||
self.h_centered_text(f"{minutes:02}:{seconds:02}", 40, 1)
|
||||
|
||||
# Current dish temperature
|
||||
if cooking_state and cooking_state.current_dish_temp is not None and cooking_state.current_dish_temp is not 0.0:
|
||||
self.display.blit(fb_image_weather_temperature_bits, 27, 2)
|
||||
self.display.text(str(int(cooking_state.current_dish_temp)), 37, 6, 1)
|
||||
self.display.ellipse(54, 6, 1, 1, 1, False)
|
||||
self.display.text("C", 56, 6, 1)
|
||||
|
||||
# Power level
|
||||
if cooking_state and cooking_state.power_level is not None:
|
||||
self.display.blit(fb_image_music_sound_wave_bits, 74, 2)
|
||||
self.display.text(f"{cooking_state.power_level}W", 92, 6, 1)
|
||||
if defrost_mode:
|
||||
self.display.blit(fb_image_CoolHiSmall_bits, 2, 2)
|
||||
else:
|
||||
self.display.blit(fb_image_CoolLoSmall_bits, 2, 2)
|
||||
|
||||
self.show()
|
||||
pass
|
||||
|
||||
def _message_at(self, y, text, alert):
|
||||
self.display.fill_rect(0, y, self.width, CHARACTER_HEIGHT + 4, 1)
|
||||
if alert:
|
||||
self.display.blit(fb_image_Alert_bits, 1, y+2)
|
||||
self.display.blit(fb_image_Alert_bits, self.width - 10, y+2)
|
||||
self.h_centered_text(text, y+2, 0)
|
||||
|
||||
def _message(self, text, alert):
|
||||
self._message_at(27, text, alert)
|
||||
|
||||
def _secondary_message(self, text, alert):
|
||||
self._message_at(40, text, alert)
|
||||
|
||||
def message(self, text, alert):
|
||||
# self.flick()
|
||||
self._message(text, alert)
|
||||
self.show()
|
||||
+239
-60
@@ -1,11 +1,10 @@
|
||||
import gc
|
||||
import sys
|
||||
import time
|
||||
import _thread
|
||||
import uasyncio as asyncio
|
||||
from machine import Pin, SoftI2C
|
||||
import framebuf
|
||||
import ssd1306
|
||||
import ujson
|
||||
|
||||
# Clean memory immediately
|
||||
gc.collect()
|
||||
@@ -16,6 +15,10 @@ from shared.uart_comm import UARTCommand, UARTCommandType
|
||||
from shared.sensors import RGBLED
|
||||
from shared.logging import log
|
||||
from shared.lora_device import LoraCommands
|
||||
from shared.alerts import Alert, AlertType, AlertManager
|
||||
from shared.microwave_state import MicrowaveState
|
||||
from shared.payloads import lora_new_alert
|
||||
from lib.microwaveScreen import MicrowaveScreen
|
||||
|
||||
# --- READ DEVICE ID ---
|
||||
try:
|
||||
@@ -26,20 +29,69 @@ except Exception:
|
||||
|
||||
# --- GLOBAL VARIABLES ---
|
||||
cooking_state = None
|
||||
cooking_start_time = None # Track start timestamp
|
||||
microwave_state: str = None
|
||||
data_queue = SafeQueue()
|
||||
lora = None
|
||||
uart_device = None
|
||||
magnetron_led = None
|
||||
display = None
|
||||
microwave_screen = None
|
||||
defrost_mode = False
|
||||
current_temp = [None, None]
|
||||
last_temp = [None, None]
|
||||
temperature_asked = False
|
||||
last_temp_request_time = 0
|
||||
alert_manager = None
|
||||
|
||||
|
||||
PING_PAYLOAD = {
|
||||
"id": DEVICE_ID,
|
||||
"type": deviceTypes.DEVICE_TYPES["MICROWAVE"]
|
||||
}
|
||||
|
||||
last_cooking_update = {}
|
||||
|
||||
|
||||
def send_cooking_update(state=None, start_time=None, estimated_remaining_time=None, paused=None):
|
||||
"""Sends a flexible COOKING_UPDATE payload containing only populated fields."""
|
||||
global last_cooking_update
|
||||
|
||||
if not lora:
|
||||
return
|
||||
|
||||
send_reliably = False
|
||||
|
||||
payload = {
|
||||
"id": DEVICE_ID,
|
||||
"action": LoraCommands.COOKING_UPDATE
|
||||
}
|
||||
|
||||
if state is not None and state != last_cooking_update.get("cooking_state"):
|
||||
payload["cooking_state"] = state
|
||||
send_reliably = True
|
||||
if estimated_remaining_time is not None:
|
||||
payload["estimated_remaining_time"] = estimated_remaining_time
|
||||
if paused is not None:
|
||||
payload["paused"] = paused
|
||||
|
||||
# Only transmit if at least one field beyond id and action was provided
|
||||
if len(payload) > 2:
|
||||
log(f"[LoRa] Sending COOKING_UPDATE payload: {payload}")
|
||||
last_cooking_update = payload.copy()
|
||||
if send_reliably:
|
||||
lora.send_reliable(payload)
|
||||
else:
|
||||
lora.send(payload)
|
||||
|
||||
|
||||
def on_new_alert(alert: Alert):
|
||||
global microwave_screen
|
||||
print(f"[Alert Manager] New alert received: {alert.to_dict()}")
|
||||
microwave_screen.message(alert.small_message if alert.small_message else alert.message, True)
|
||||
|
||||
def init_hardware():
|
||||
"""Initializes all hardware components."""
|
||||
global lora, uart_device, magnetron_led, display
|
||||
global lora, uart_device, magnetron_led, microwave_screen
|
||||
|
||||
print("[Main] Initializing hardware peripherals...")
|
||||
|
||||
@@ -47,7 +99,15 @@ def init_hardware():
|
||||
vext = Pin(19, Pin.OUT)
|
||||
vext.value(0)
|
||||
time.sleep_ms(100)
|
||||
|
||||
|
||||
# Init OLED Display
|
||||
scl_pin = Pin(18, Pin.OUT, pull=Pin.PULL_UP)
|
||||
sda_pin = Pin(17, Pin.OUT, pull=Pin.PULL_UP)
|
||||
display_i2c = SoftI2C(scl=scl_pin, sda=sda_pin, freq=100000)
|
||||
display = ssd1306.SSD1306_I2C(128, 64, display_i2c, addr=0x3C)
|
||||
microwave_screen = MicrowaveScreen(display)
|
||||
microwave_screen.bootScreen()
|
||||
|
||||
# Init LoRa
|
||||
lora = get_lora()
|
||||
lora.configure(freq=868.1, sf=7)
|
||||
@@ -57,23 +117,14 @@ def init_hardware():
|
||||
magnetron_led.color = RGBLED.WHITE_YELLOW
|
||||
magnetron_led.off()
|
||||
|
||||
# Init OLED Display
|
||||
scl_pin = Pin(18, Pin.OUT, pull=Pin.PULL_UP)
|
||||
sda_pin = Pin(17, Pin.OUT, pull=Pin.PULL_UP)
|
||||
display_i2c = SoftI2C(scl=scl_pin, sda=sda_pin, freq=100000)
|
||||
display = ssd1306.SSD1306_I2C(128, 64, display_i2c, addr=0x3C)
|
||||
display.text("Booting...", 1, 2, 1)
|
||||
display.show()
|
||||
|
||||
# Init UART
|
||||
uart_device = get_uart(uart_id=1, tx_pin=46, rx_pin=45)
|
||||
|
||||
print(f"[Main] ESP32 initialized with ID: '{DEVICE_ID}' (Type: {deviceTypes.DEVICE_TYPES['MICROWAVE']})")
|
||||
|
||||
|
||||
# --- DEDICATED LORA HARDWARE THREAD ---
|
||||
def lora_hardware_thread():
|
||||
"""Runs in a separate OS thread to keep the LoRa radio in continuous RX mode."""
|
||||
# --- ASYNC LORA TASK (REPLACES _THREAD) ---
|
||||
async def lora_rx_and_heartbeat_task():
|
||||
"""Replaces the hardware OS thread with a non-blocking async task to avoid SPI collisions."""
|
||||
last_heartbeat_time = 0
|
||||
|
||||
while True:
|
||||
@@ -82,70 +133,144 @@ def lora_hardware_thread():
|
||||
# 1. Send periodic heartbeat
|
||||
if now - last_heartbeat_time >= config.LORA_HEARTBEAT_INTERVAL:
|
||||
last_heartbeat_time = now
|
||||
print("\n[LoRa Thread] Sending Heartbeat...")
|
||||
log("\n[LoRa Task] Sending Heartbeat...")
|
||||
if lora:
|
||||
lora.send(PING_PAYLOAD)
|
||||
# CRITICAL: Force the radio chip out of Standby mode and back into RX mode
|
||||
if hasattr(lora, 'receive'):
|
||||
lora.receive()
|
||||
|
||||
# 2. Blocking 300ms RX listen window (keeps radio actively listening)
|
||||
# 2. Listen for incoming packets (Must be longer than LoRa Time-on-Air)
|
||||
if lora:
|
||||
paquet = lora.receive_reliable(timeout_ms=300)
|
||||
paquet = lora.receive_reliable(timeout_ms=500)
|
||||
if paquet is not None:
|
||||
log(f"[LoRa Thread] New Packet Received: {paquet}")
|
||||
log(f"[LoRa Task] New Packet Received: {paquet}")
|
||||
data_queue.put(paquet)
|
||||
|
||||
time.sleep_ms(10)
|
||||
|
||||
# Give control back to event loop
|
||||
await asyncio.sleep_ms(10)
|
||||
|
||||
# --- COOKING STATE CALLBACKS ---
|
||||
def cooking_state_temperature_provider():
|
||||
return 22.0, 29.0 # TODO: Replace with real temperature reading
|
||||
global current_temp, last_temp, temperature_asked, last_temp_request_time, uart_device
|
||||
|
||||
def temp_is_invalid(temp):
|
||||
return (
|
||||
temp is None
|
||||
or not isinstance(temp, (list, tuple))
|
||||
or len(temp) < 2
|
||||
or temp[0] is None
|
||||
or temp[1] is None
|
||||
)
|
||||
|
||||
# 1. Check for UART request timeout (reset lock if 3 seconds pass without a response)
|
||||
now = time.time()
|
||||
if temperature_asked and (now - last_temp_request_time > 3):
|
||||
print("[CookingState] Temperature request timed out. Retrying UART request...")
|
||||
temperature_asked = False
|
||||
|
||||
# 2. Trigger new UART request if idle
|
||||
if not temperature_asked and uart_device:
|
||||
temperature_asked = True
|
||||
last_temp_request_time = now
|
||||
uart_device.send_as_command(UARTCommand(UARTCommandType.TEMPERATURE_REQUEST, {}))
|
||||
|
||||
# 3. Handle fresh incoming reading
|
||||
if not temp_is_invalid(current_temp):
|
||||
temps = [float(current_temp[0]), float(current_temp[1])]
|
||||
last_temp = [temps[0], temps[1]]
|
||||
current_temp = [None, None]
|
||||
|
||||
if config.DEBUG_TEMPERATURE_ALERT or temps[0] > config.MAX_ALLOWED_TEMPERATURE_COOKING_COMPARTMENT or temps[1] > config.MAX_ALLOWED_TEMPERATURE_TECHNICAL_COMPARTMENT:
|
||||
print(f"[CookingState] ALERT: Unsafe temperature detected! Dish: {temps[0]}°C, Ambient: {temps[1]}°C")
|
||||
# Send alert to orchestrator via LoRa
|
||||
alert = Alert(
|
||||
AlertType.TEMPERATURE_ALERT,
|
||||
f"Unsafe temperature detected! Dish: {temps[0]}/{config.MAX_ALLOWED_TEMPERATURE_COOKING_COMPARTMENT}°C, Ambient: {temps[1]}/{config.MAX_ALLOWED_TEMPERATURE_TECHNICAL_COMPARTMENT}°C",
|
||||
small_message=f"Temp too high"
|
||||
)
|
||||
alert_manager.add_alert(alert)
|
||||
if lora:
|
||||
lora.send_reliable(lora_new_alert(alert, with_message=True), max_retries=5)
|
||||
cooking_state.set_state(cookingState.CookingStates.ALERT)
|
||||
cooking_state.pause()
|
||||
|
||||
return temps
|
||||
|
||||
# 4. Fallback: Use last valid reading
|
||||
if not temp_is_invalid(last_temp):
|
||||
return [float(last_temp[0]), float(last_temp[1])]
|
||||
|
||||
# 5. Default fallback if no data has ever arrived
|
||||
return (0.0, 0.0)
|
||||
|
||||
def cooking_state_on_state_change(state):
|
||||
global cooking_start_time, uart_device
|
||||
print(f"[CookingState] State changed to: {state.state}")
|
||||
|
||||
if state.paused or state.state == cookingState.CookingStates.DONE or state.state == cookingState.CookingStates.IDLE:
|
||||
if state.paused or state.state in (cookingState.CookingStates.DONE, cookingState.CookingStates.IDLE, cookingState.CookingStates.ALERT):
|
||||
magnetron_led.off()
|
||||
else:
|
||||
magnetron_led.on()
|
||||
|
||||
# Send state updates to WiFi board and Orchestrator
|
||||
if uart_device:
|
||||
if uart_device != None:
|
||||
uart_device.send_as_command(UARTCommand(UARTCommandType.COOKING_STATE_UPDATE, {"state": state.state}))
|
||||
if lora:
|
||||
lora.send_reliable({"id": DEVICE_ID, "new_cooking_state": state.state})
|
||||
|
||||
# Update OLED display
|
||||
if display:
|
||||
display.fill(0)
|
||||
display.text(cookingState.CookingStates.get_state_name(state.state), 1, 2, 1)
|
||||
display.show()
|
||||
# Send LoRa update with start time and remaining time if beginning cooking process
|
||||
send_cooking_update(
|
||||
state=state.state,
|
||||
start_time=cooking_start_time if state.state == cookingState.CookingStates.COOKING else None,
|
||||
estimated_remaining_time=state.get_remaining_time() if hasattr(state, "get_remaining_time") else None,
|
||||
paused=state.paused
|
||||
)
|
||||
|
||||
update_screen()
|
||||
|
||||
def cooking_state_on_refresh(state):
|
||||
# TODO: Show screen information
|
||||
pass
|
||||
# Update OLED display
|
||||
update_screen()
|
||||
|
||||
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
|
||||
|
||||
# Send update on pause/resume toggle
|
||||
send_cooking_update(
|
||||
state=state.state,
|
||||
paused=state.paused,
|
||||
estimated_remaining_time=state.get_remaining_time()
|
||||
)
|
||||
|
||||
# --- SCREEN UPDATE ---
|
||||
def update_screen():
|
||||
global microwave_screen, cooking_state, defrost_mode, microwave_state
|
||||
|
||||
if microwave_screen:
|
||||
microwave_screen.update(cooking_state, defrost_mode, microwave_state)
|
||||
|
||||
|
||||
# --- ASYNC TASKS ---
|
||||
|
||||
async def uart_polling_task():
|
||||
"""Polls UART for incoming messages from the WiFi board."""
|
||||
global cooking_state
|
||||
global cooking_state, cooking_start_time, temperature_asked, microwave_state
|
||||
|
||||
while True:
|
||||
if uart_device and uart_device.any():
|
||||
command = uart_device.read_as_command()
|
||||
if command:
|
||||
print(f"[UART Task] Received command from WiFi Board: {command.command_type}")
|
||||
log(f"[UART Task] Received command from WiFi Board: {command.command_type}")
|
||||
if command.command_type == UARTCommandType.COOKING_PARAMS:
|
||||
params = command.payload
|
||||
print(f"[UART Task] Cooking parameters received: {params}")
|
||||
|
||||
uart_device.send_as_command(UARTCommand(UARTCommandType.TEMPERATURE_REQUEST, {}))
|
||||
|
||||
# Track start time timestamp
|
||||
cooking_start_time = time.time()
|
||||
|
||||
cooking_state = cookingState.CookingState(
|
||||
cook_time=params["cook_time"],
|
||||
power_level=params["power_level"],
|
||||
@@ -156,8 +281,19 @@ async def uart_polling_task():
|
||||
cooking_state.set_refresh_callback(cooking_state_on_refresh)
|
||||
cooking_state.set_pause_callback(cooking_state_on_pause)
|
||||
|
||||
await asyncio.sleep_ms(20)
|
||||
await asyncio.sleep_ms(200)
|
||||
cooking_state_on_state_change(cooking_state)
|
||||
microwave_state = MicrowaveState.COOKING
|
||||
elif command.command_type == UARTCommandType.TEMPERATURE_RESPONSE:
|
||||
try:
|
||||
# Check if payload is already a dict or needs JSON decoding
|
||||
payload = ujson.loads(command.payload) if isinstance(command.payload, str) else command.payload
|
||||
current_temp[0] = payload.get("dish_temp", 0.0)
|
||||
current_temp[1] = payload.get("ambient_temp", 0.0)
|
||||
except Exception as e:
|
||||
print(f"[UART Task] Error parsing temperature payload: {e}")
|
||||
finally:
|
||||
temperature_asked = False
|
||||
else:
|
||||
print(f"[UART Task] Unknown command type received: {command.command_type}")
|
||||
|
||||
@@ -166,7 +302,7 @@ async def uart_polling_task():
|
||||
|
||||
async def lora_process_task():
|
||||
"""Consumes packets pushed to data_queue by the LoRa hardware thread."""
|
||||
global cooking_state
|
||||
global cooking_state, defrost_mode, microwave_screen, microwave_state
|
||||
|
||||
while True:
|
||||
while not data_queue.empty():
|
||||
@@ -174,34 +310,79 @@ async def lora_process_task():
|
||||
if paquet and not paquet.get("raw"):
|
||||
data = paquet.get("data", {})
|
||||
|
||||
# RECIPIENT FILTERING: Drop packets not addressed to this ESP32 or ALL
|
||||
target = data.get("target_id") or data.get("recipient")
|
||||
if target and target not in [DEVICE_ID, "ALL", "BROADCAST"]:
|
||||
log(f"[LoRa Process] Ignoring packet intended for {target}")
|
||||
continue
|
||||
|
||||
if "action" in data:
|
||||
if data["action"] == LoraCommands.TOGGLE_PAUSE:
|
||||
action = data["action"]
|
||||
if action == LoraCommands.TOGGLE_PAUSE:
|
||||
if cooking_state is not None:
|
||||
if cooking_state.state == cookingState.CookingStates.DONE:
|
||||
print("[LoRa Process] Cooking is done. Resetting microwave for the next session.")
|
||||
print("[LoRa Process] Cooking is done. Resetting microwave for next session.")
|
||||
cooking_state.set_state(cookingState.CookingStates.IDLE)
|
||||
await asyncio.sleep_ms(20)
|
||||
cooking_state = None
|
||||
else:
|
||||
cooking_state.toggle_pause()
|
||||
if cooking_state.paused:
|
||||
print("[LoRa Process] Cooking paused via orchestrator command.")
|
||||
else:
|
||||
print("[LoRa Process] Cooking resumed via orchestrator command.")
|
||||
print(f"[LoRa Process] Cooking paused state toggled to {cooking_state.paused}")
|
||||
else:
|
||||
log("[LoRa Process] No active cooking state to toggle pause/resume.")
|
||||
|
||||
elif action == LoraCommands.TOGGLE_DEFROST:
|
||||
print("[LoRa Process] Toggling defrost mode via orchestrator command.")
|
||||
defrost_mode = data.get("defrost_state", False)
|
||||
update_screen()
|
||||
|
||||
elif action == LoraCommands.NEW_ALERT:
|
||||
alert: Alert | None = None
|
||||
if data.get("alert_type") == AlertType.TEMPERATURE_ALERT:
|
||||
alert = Alert(data.get("alert_type"), data.get("alert_message", "Temp too high"), redistributed=True)
|
||||
elif data.get("alert_type") == AlertType.COOKING_SAFETY:
|
||||
alert = Alert(data.get("alert_type"), data.get("alert_message", "Unsafe area"), redistributed=True)
|
||||
|
||||
await asyncio.sleep_ms(50)
|
||||
if alert:
|
||||
alert.timestamp = data.get("timestamp", time.time())
|
||||
alert_manager.add_alert(alert)
|
||||
microwave_state = MicrowaveState.ALERT
|
||||
if cooking_state is not None:
|
||||
cooking_state.set_state(cookingState.CookingStates.ALERT)
|
||||
|
||||
elif action == LoraCommands.MICROVAVE_STATE_UPDATE:
|
||||
new_state = data.get("new_microwave_state")
|
||||
if new_state:
|
||||
print(f"[LoRa Process] Microwave state update received: {new_state}")
|
||||
microwave_state = new_state
|
||||
if new_state not in (MicrowaveState.ALERT): # Not alert so we can show the error
|
||||
update_screen()
|
||||
if new_state == MicrowaveState.IDLE:
|
||||
if cooking_state is not None:
|
||||
cooking_state.set_state(cookingState.CookingStates.IDLE)
|
||||
await asyncio.sleep_ms(20)
|
||||
|
||||
await asyncio.sleep_ms(100)
|
||||
|
||||
|
||||
async def cooking_loop_task():
|
||||
"""Ticks the cooking state and logs information periodically."""
|
||||
"""Ticks the cooking state and logs information periodically without flooding output."""
|
||||
log_counter = 0
|
||||
while True:
|
||||
if cooking_state is not None:
|
||||
cooking_state.update_tick()
|
||||
print(f"[Cooking Task] State: {cooking_state.state}, Temp: {cooking_state.current_dish_temp}, "
|
||||
f"Paused: {cooking_state.paused}, Remaining: {cooking_state.get_remaining_time():.2f}s, "
|
||||
f"Est. Remaining: {cooking_state.get_remaining_time_estimation():.2f}s")
|
||||
log_counter += 1
|
||||
|
||||
# Print log output and send periodic updates every 5 seconds (10 ticks x 500ms)
|
||||
if log_counter % 10 == 0:
|
||||
print(f"[Cooking Task] State: {cooking_state.state}, Temp: {cooking_state.current_dish_temp}, "
|
||||
f"Paused: {cooking_state.paused}, Remaining: {cooking_state.get_remaining_time():.2f}s")
|
||||
|
||||
# Send periodic time remaining update while actively cooking
|
||||
if not cooking_state.paused and cooking_state.state == cookingState.CookingStates.COOKING:
|
||||
send_cooking_update(
|
||||
estimated_remaining_time=cooking_state.get_remaining_time()
|
||||
)
|
||||
|
||||
await asyncio.sleep_ms(500)
|
||||
|
||||
@@ -215,26 +396,24 @@ async def memory_cleanup_task():
|
||||
|
||||
# --- BOOTSTRAP ---
|
||||
async def main():
|
||||
global alert_manager
|
||||
print("[Main] Starting application...")
|
||||
|
||||
init_hardware()
|
||||
|
||||
# Launch dedicated hardware thread for LoRa RX
|
||||
try:
|
||||
_thread.stack_size(16 * 1024)
|
||||
except Exception:
|
||||
pass
|
||||
_thread.start_new_thread(lora_hardware_thread, ())
|
||||
print("[Main] LoRa hardware background thread started.")
|
||||
alert_manager = AlertManager()
|
||||
alert_manager.set_on_alert_callback(on_new_alert)
|
||||
|
||||
# Launch background async tasks
|
||||
# Launch all tasks within the same single-threaded uasyncio event loop
|
||||
asyncio.create_task(lora_rx_and_heartbeat_task())
|
||||
asyncio.create_task(uart_polling_task())
|
||||
asyncio.create_task(lora_process_task())
|
||||
asyncio.create_task(cooking_loop_task())
|
||||
asyncio.create_task(memory_cleanup_task())
|
||||
|
||||
print("[Main] All async tasks running concurrently!")
|
||||
|
||||
|
||||
update_screen()
|
||||
# Keep main task alive indefinitely
|
||||
while True:
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
@@ -195,14 +195,16 @@ async def uart_task():
|
||||
try:
|
||||
cmd = uart_device.read_as_command()
|
||||
|
||||
if cmd:
|
||||
if cmd and hasattr(cmd, "command_type"):
|
||||
print("[UART] Command received from LoRa board:", cmd.command_type, cmd.payload)
|
||||
if (
|
||||
hasattr(cmd, "command_type")
|
||||
and cmd.command_type == UARTCommandType.COOKING_STATE_UPDATE
|
||||
):
|
||||
if cmd.command_type == UARTCommandType.COOKING_STATE_UPDATE:
|
||||
if cooking_state:
|
||||
cooking_state.set_state(cmd.payload.get("state"))
|
||||
elif cmd.command_type == UARTCommandType.TEMPERATURE_REQUEST:
|
||||
uart_device.send_as_command(UARTCommand(UARTCommandType.TEMPERATURE_RESPONSE, payloads.lora_sensor_data(
|
||||
mlx_temperature_sensor.read_object_temp() if mlx_temperature_sensor else 0,
|
||||
mlx_temperature_sensor.read_ambient_temp() if mlx_temperature_sensor else 0
|
||||
)))
|
||||
except Exception as e:
|
||||
print("[UART Task] Error reading command:", e)
|
||||
|
||||
@@ -221,7 +223,7 @@ async def connect_mqtt_async():
|
||||
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",
|
||||
client_id="smartwave-esp32-demo", # TODO : Use DEVICE_ID instead of hardcoding
|
||||
use_tls=True,
|
||||
cafile=MQTT_CA_FILE,
|
||||
keepalive=30,
|
||||
|
||||
@@ -2,4 +2,11 @@
|
||||
# IP Adresses
|
||||
|
||||
- AP (dynamic) : `10.110.0.190`
|
||||
- Generated Wifi : `192.168.50.1`
|
||||
- Generated Wifi : `192.168.50.1`
|
||||
|
||||
# RFID
|
||||
|
||||
## Cards
|
||||
|
||||
- 2700423026
|
||||
- 270042302B
|
||||
@@ -1,11 +1,32 @@
|
||||
services:
|
||||
otbr:
|
||||
image: openthread/border-router:latest
|
||||
container_name: otbr
|
||||
restart: unless-stopped
|
||||
# privileged: true
|
||||
network_mode: host
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
environment:
|
||||
- OT_RCP_DEVICE=spinel+hdlc+uart:///dev/ttyUSB1?uart-baudrate=460800
|
||||
- OT_INFRA_IF=wlan1
|
||||
- OT_THREAD_IF=wpan0
|
||||
- OT_LOG_LEVEL=6
|
||||
- FIREWALL=0
|
||||
- NAT64=1
|
||||
devices:
|
||||
- /dev/serial/by-id/usb-Silicon_Labs_CP2102N_USB_to_UART_Bridge_Controller_ba78c1181566ee11b42a8d6293cd958c-if00-port0:/dev/ttyUSB1
|
||||
- /dev/net/tun:/dev/net/tun
|
||||
volumes:
|
||||
- otbr-data:/data
|
||||
|
||||
mqtt-broker:
|
||||
image: eclipse-mosquitto:2.0
|
||||
container_name: mqtt-broker
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
environment:
|
||||
MQTT_TLS_ENABLED: ${MQTT_TLS_ENABLED:-true}
|
||||
# restart: unless-stopped
|
||||
ports:
|
||||
- "192.168.50.1:8884:8884"
|
||||
volumes:
|
||||
- ./mqtt/mosquitto-tls.conf:/mosquitto/config/mosquitto-tls.conf:ro
|
||||
- ./mqtt/mosquitto-plain.conf:/mosquitto/config/mosquitto-plain.conf:ro
|
||||
@@ -14,8 +35,8 @@ services:
|
||||
- mqtt-data:/mosquitto/data
|
||||
- mqtt-log:/mosquitto/log
|
||||
command: ["/bin/sh", "/scripts/start-broker.sh"]
|
||||
# command: ["tail", "-f", "/dev/null"] # Do nothing
|
||||
|
||||
volumes:
|
||||
otbr-data:
|
||||
mqtt-data:
|
||||
mqtt-log:
|
||||
mqtt-log:
|
||||
@@ -0,0 +1 @@
|
||||
zeJlYcaW0dji4A88Q3OGfvqgqXUuUAm0
|
||||
@@ -0,0 +1,135 @@
|
||||
import asyncio
|
||||
import time
|
||||
import requests
|
||||
import datetime
|
||||
from typing import Dict
|
||||
from zeroconf import ServiceStateChange
|
||||
from zeroconf.asyncio import AsyncZeroconf, AsyncServiceBrowser, AsyncServiceInfo
|
||||
|
||||
SERVICE_TYPE = "_displaytcp._tcp.local."
|
||||
|
||||
class DisplayManager:
|
||||
def __init__(self, endpoint_path: str = "/api/display"):
|
||||
self.endpoint_path = endpoint_path
|
||||
self.screens: Dict[str, str] = {} # Map: service_name -> URL
|
||||
self.aiozc: AsyncZeroconf | None = None
|
||||
self.browser: AsyncServiceBrowser | None = None
|
||||
self._on_screens_change_callback = None # Callback for screen changes
|
||||
|
||||
async def start(self):
|
||||
"""Starts dynamic mDNS discovery for e-Paper screens."""
|
||||
self.aiozc = AsyncZeroconf()
|
||||
self.browser = AsyncServiceBrowser(
|
||||
self.aiozc.zeroconf,
|
||||
SERVICE_TYPE,
|
||||
handlers=[self._on_service_state_change]
|
||||
)
|
||||
print(f"[DisplayManager] 🔍 Listening for mDNS screens ({SERVICE_TYPE})...")
|
||||
|
||||
def _on_service_state_change(self, zeroconf, service_type, name, state_change):
|
||||
asyncio.create_task(self._update_service(name, state_change))
|
||||
|
||||
async def _update_service(self, name: str, state_change: ServiceStateChange):
|
||||
if state_change in (ServiceStateChange.Added, ServiceStateChange.Updated):
|
||||
info = AsyncServiceInfo(SERVICE_TYPE, name)
|
||||
if await info.async_request(self.aiozc.zeroconf, 3000):
|
||||
addresses = info.parsed_addresses()
|
||||
if addresses:
|
||||
# Prefer IPv4 if available, fallback to IPv6 (Thread)
|
||||
ipv4_addrs = [a for a in addresses if ":" not in a]
|
||||
|
||||
if ipv4_addrs:
|
||||
ip_str = ipv4_addrs[0]
|
||||
else:
|
||||
raw_ip = addresses[0]
|
||||
# Wrap IPv6 addresses in square brackets for valid HTTP URLs
|
||||
ip_str = f"[{raw_ip}]" if not raw_ip.startswith("[") else raw_ip
|
||||
|
||||
port = info.port or 5000
|
||||
url = f"http://{ip_str}:{port}{self.endpoint_path}"
|
||||
|
||||
self.screens[name] = url
|
||||
if self._on_screens_change_callback:
|
||||
self._on_screens_change_callback(self.screens, is_removed=False, modified_name=name)
|
||||
print(f"[DisplayManager] 📺 Screen registered: {name} -> {url}")
|
||||
|
||||
elif state_change == ServiceStateChange.Removed:
|
||||
if name in self.screens:
|
||||
print(f"[DisplayManager] ❌ Screen disconnected: {name}")
|
||||
self.screens.pop(name, None)
|
||||
if self._on_screens_change_callback:
|
||||
self._on_screens_change_callback(self.screens, is_removed=True, modified_name=name)
|
||||
|
||||
def set_on_screens_change_callback(self, callback):
|
||||
"""Set a callback function to be called when screens are added or removed."""
|
||||
self._on_screens_change_callback = callback
|
||||
|
||||
async def broadcast_state(self, microwave_states: dict, cloud_alert: bool):
|
||||
"""Broadcasts the system state JSON to all discovered screens concurrently."""
|
||||
if not self.screens:
|
||||
return
|
||||
|
||||
now_time = time.time()
|
||||
update_time_str = datetime.datetime.now().strftime("%H:%M")
|
||||
|
||||
microwaves_list = []
|
||||
for mw_id, state_info in microwave_states.items():
|
||||
# Extract state properties safely
|
||||
state_val = state_info.cooking_state
|
||||
paused = state_info.paused
|
||||
rem_time = state_info.cooking_estimated_remaining_time
|
||||
start_t = state_info.cooking_start_time
|
||||
name = state_info.friendly_name
|
||||
|
||||
# Calculate progress percentage (0 to 100)
|
||||
progress = 0
|
||||
if start_t > 0 and rem_time > 0:
|
||||
elapsed = now_time - start_t
|
||||
total = elapsed + rem_time
|
||||
if total > 0:
|
||||
progress = int((elapsed / total) * 100)
|
||||
progress = max(0, min(100, progress))
|
||||
|
||||
microwaves_list.append({
|
||||
"id": mw_id,
|
||||
"name": name,
|
||||
"state": state_val,
|
||||
"paused": paused,
|
||||
"remaining_time": rem_time,
|
||||
"progress": progress
|
||||
})
|
||||
|
||||
payload = {
|
||||
"cloud_alert": cloud_alert,
|
||||
"update_time": update_time_str,
|
||||
"microwaves": microwaves_list
|
||||
}
|
||||
|
||||
# Dispatch POST requests in parallel without blocking the main loop
|
||||
tasks = [
|
||||
self._post_to_screen(name, url, payload)
|
||||
for name, url in list(self.screens.items())
|
||||
]
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
async def _post_to_screen(self, name: str, url: str, payload: dict):
|
||||
try:
|
||||
response = await asyncio.to_thread(
|
||||
requests.post,
|
||||
url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=3.0
|
||||
)
|
||||
response.raise_for_status()
|
||||
print(f"[DisplayManager] Updated {name}")
|
||||
except Exception as e:
|
||||
print(f"[DisplayManager] Error pushing state to {name}: {e}")
|
||||
|
||||
async def stop(self):
|
||||
"""Clean up mDNS browser and Zeroconf instance."""
|
||||
if self.browser:
|
||||
await self.browser.async_cancel()
|
||||
if self.aiozc:
|
||||
await self.aiozc.async_close()
|
||||
print("[DisplayManager] Stopped service browser.")
|
||||
+17
-12
@@ -3,6 +3,7 @@ set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
REPO_ROOT=$(dirname -- "$SCRIPT_DIR")
|
||||
VENV_DIR="$SCRIPT_DIR/venv"
|
||||
PYTHON_BIN="${PYTHON_BIN:-python3}"
|
||||
PYTHON_SCRIPT="${1:-$SCRIPT_DIR/main.py}"
|
||||
REQUIREMENTS_FILE="$SCRIPT_DIR/requirements.txt"
|
||||
@@ -19,24 +20,28 @@ cd "$SCRIPT_DIR"
|
||||
|
||||
# 2. On lance Docker en arrière-plan
|
||||
echo "Démarrage des conteneurs Docker..."
|
||||
# docker compose pull
|
||||
docker compose up -d --remove-orphans
|
||||
|
||||
# 3. Installation des dépendances (sans '--user' si on est déjà root sous systemd)
|
||||
# 3. Gestion propre de l'environnement virtuel Python (contourne PEP 668)
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
echo "Création de l'environnement virtuel Python..."
|
||||
"$PYTHON_BIN" -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
# Utiliser le pip/python du venv
|
||||
VENV_PYTHON="$VENV_DIR/bin/python"
|
||||
VENV_PIP="$VENV_DIR/bin/pip"
|
||||
|
||||
if [ -f "$REQUIREMENTS_FILE" ]; then
|
||||
echo "Vérification des dépendances Python..."
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
"$PYTHON_BIN" -m pip install -r "$REQUIREMENTS_FILE"
|
||||
else
|
||||
"$PYTHON_BIN" -m pip install --user -r "$REQUIREMENTS_FILE"
|
||||
fi
|
||||
echo "Vérification et installation des dépendances dans le venv..."
|
||||
"$VENV_PIP" install --upgrade pip >/dev/null 2>&1 || true
|
||||
"$VENV_PIP" install -r "$REQUIREMENTS_FILE"
|
||||
fi
|
||||
|
||||
# 4. Sécurité anti-conflit : On vérifie si Docker n'a pas verrouillé le port USB de la LA66
|
||||
PORT="/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0"
|
||||
if [ -e "$PORT" ]; then
|
||||
echo "Vérification de la disponibilité du port série..."
|
||||
# Si le port est occupé, on force sa libération en tuant le processus docker/screen persistant
|
||||
if lsof "$PORT" >/dev/null 2>&1; then
|
||||
echo "Le port série est occupé ! Libération en cours..."
|
||||
fuser -k "$PORT" || true
|
||||
@@ -44,7 +49,7 @@ if [ -e "$PORT" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# 5. Lancement propre du script Python
|
||||
# 5. Lancement propre du script Python via le venv
|
||||
echo "Launching Python script: $PYTHON_SCRIPT"
|
||||
cd "$REPO_ROOT"
|
||||
exec "$PYTHON_BIN" "$PYTHON_SCRIPT"
|
||||
cd "$SCRIPT_DIR"
|
||||
exec "$VENV_PYTHON" "$PYTHON_SCRIPT"
|
||||
@@ -0,0 +1,61 @@
|
||||
from datetime import datetime
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
async def get_systemd_logs(lines: int = 200, service_name: str = "smartwave") -> list[dict]:
|
||||
"""
|
||||
Asynchronously fetches the last N log entries from a systemd service
|
||||
using native asyncio subprocess execution.
|
||||
"""
|
||||
cmd = (
|
||||
"journalctl",
|
||||
"-u", service_name,
|
||||
"-n", str(lines),
|
||||
"-o", "json",
|
||||
"--no-pager"
|
||||
)
|
||||
|
||||
try:
|
||||
# Create non-blocking child process
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
|
||||
# Await completion and read output non-blockingly
|
||||
stdout_bytes, stderr_bytes = await proc.communicate()
|
||||
|
||||
if proc.returncode != 0:
|
||||
print(f"[Logs] journalctl error (code {proc.returncode}): {stderr_bytes.decode('utf-8', errors='replace')}")
|
||||
return []
|
||||
|
||||
structured_logs = []
|
||||
stdout_text = stdout_bytes.decode("utf-8", errors="replace")
|
||||
|
||||
for line in stdout_text.strip().split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
|
||||
raw_ts = entry.get("__REALTIME_TIMESTAMP")
|
||||
timestamp_iso = None
|
||||
if raw_ts:
|
||||
timestamp_iso = datetime.fromtimestamp(int(raw_ts) / 1_000_000).isoformat()
|
||||
|
||||
structured_logs.append({
|
||||
"timestamp": timestamp_iso,
|
||||
"timestamp_us": int(raw_ts) if raw_ts else None,
|
||||
"message": entry.get("MESSAGE", ""),
|
||||
"priority": entry.get("PRIORITY"),
|
||||
"pid": entry.get("_PID"),
|
||||
})
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
|
||||
return structured_logs
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Logs] Failed to read systemd logs for '{service_name}': {e}")
|
||||
return []
|
||||
+587
-113
@@ -1,19 +1,34 @@
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
import traceback
|
||||
import asyncio
|
||||
import requests
|
||||
import os
|
||||
|
||||
from orchestrateur.sensors import gps
|
||||
from shared.microwave_state import MicrowaveState, MicrowaveStateFields
|
||||
from shared import get_lora, get_mqtt_client, deviceTypes, config, payloads, db
|
||||
from shared.logging import log
|
||||
from shared.cookingState import CookingStates
|
||||
from shared.lora_device import LoraCommands
|
||||
from sensors import ultrasonicRanger, temp_hum, button, camera
|
||||
from shared.alerts import AlertManager, Alert, AlertType
|
||||
from sensors import ultrasonicRanger, temp_hum, button, camera, buzzer, rgb_lcd, gps, rfid_reader
|
||||
from lib.systemd_logs import get_systemd_logs
|
||||
from external_display_manager import DisplayManager
|
||||
from rgb_lcd_manager import RGBLCDManager
|
||||
from web_server import start_technician_web_server
|
||||
|
||||
rgb_lcd.setRGB(255, 255, 255, brightness=0.8)
|
||||
rgb_lcd.setText("SmartWave\nOrchestrateur")
|
||||
|
||||
# --- CONFIGURATION CONSTANTS ---
|
||||
DISPLAY_DEBOUNCE_SECONDS = 3 # Seconds to wait after first change before broadcasting
|
||||
TECHNICIAN_MODE = False
|
||||
def is_technician_mode_active() -> bool:
|
||||
return TECHNICIAN_MODE
|
||||
|
||||
# --- DB SETUP ---
|
||||
DB_PATH = "orchestrateur/db.sqlite"
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
DB_PATH = os.path.join(BASE_DIR, "db.sqlite")
|
||||
|
||||
def init_db():
|
||||
"""Ensures the connected_components table exists on startup."""
|
||||
@@ -39,6 +54,18 @@ def save_connected_component(component_id: str, component_type: str):
|
||||
"""
|
||||
db.execute(DB_PATH, sql, (str(component_id), str(component_type), current_time))
|
||||
print(f"[DB] Component saved/updated -> ID: {component_id}, Type: {component_type}, Timestamp: {current_time}")
|
||||
|
||||
def remove_connected_component(component_id: str):
|
||||
"""Removes a component from the database (blocking sync worker)."""
|
||||
sql = "DELETE FROM connected_components WHERE id = ?;"
|
||||
db.execute(DB_PATH, sql, (str(component_id),))
|
||||
print(f"[DB] Component removed -> ID: {component_id}")
|
||||
|
||||
def get_connected_components():
|
||||
"""Returns a list of all connected components from the database."""
|
||||
sql = "SELECT id, type, timestamp FROM connected_components;"
|
||||
rows = db.fetchall(DB_PATH, sql)
|
||||
return [{"id": row[0], "type": row[1], "timestamp": row[2]} for row in rows]
|
||||
|
||||
# --- Read Unique Device ID ---
|
||||
def get_device_id():
|
||||
@@ -50,31 +77,77 @@ def get_device_id():
|
||||
pass
|
||||
return "RPI_Orchestrateur_Default"
|
||||
|
||||
DEVICE_ID = get_device_id()
|
||||
def get_api_key():
|
||||
for path in ["device_api_key.txt", "/home/pi/SmartWave/orchestrateur/device_api_key.txt"]:
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
return f.read().strip()
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
# --- 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
|
||||
DEVICE_ID = get_device_id()
|
||||
API_KEY = get_api_key()
|
||||
|
||||
# --- DISPLAY QUEUE & NOTIFICATION HELPERS ---
|
||||
display_update_queue = None
|
||||
|
||||
def notify_display_update():
|
||||
"""Safely adds an update event timestamp to the display update queue."""
|
||||
|
||||
if display_update_queue is not None:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.call_soon_threadsafe(display_update_queue.put_nowait, time.time())
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
def on_microwave_change(state: MicrowaveState, field: MicrowaveStateFields):
|
||||
if field != MicrowaveStateFields.STATE: # No external screen or lcd uses this field, so we can skip updates for it
|
||||
notify_display_update()
|
||||
|
||||
if field == MicrowaveStateFields.STATE and state.state in (MicrowaveState.IDLE, MicrowaveState.DONE, MicrowaveState.ANALYZING, MicrowaveState.WAITING_FOR_CLOUD):
|
||||
# Send to LoRa device if state changed
|
||||
lora.send_reliable(payloads.lora_microwave_state(state.state), max_retries=5)
|
||||
|
||||
if field == MicrowaveStateFields.COOKING_STATE:
|
||||
update_lcd_microwave_count()
|
||||
if state.cooking_state in (CookingStates.DONE, CookingStates.STIRRING_REQUIRED, CookingStates.ALERT):
|
||||
# Trigger buzzer for user attention
|
||||
beats = 5 if state.cooking_state == CookingStates.ALERT else (1 if state.cooking_state == CookingStates.STIRRING_REQUIRED else 3)
|
||||
buzzer.buzzer_siren(beats_nb=beats)
|
||||
elif state.cooking_state == CookingStates.DONE and state.state == MicrowaveState.COOKING:
|
||||
state.set_state(MicrowaveState.DONE)
|
||||
print(f"[{state.microwave_id}] Cooking finished. Waiting for user to remove dish.")
|
||||
|
||||
def set_cloud_alert(state: bool):
|
||||
"""Setter for cloud_alert that triggers a display update when changed."""
|
||||
global cloud_alert
|
||||
|
||||
if cloud_alert != state:
|
||||
cloud_alert = state
|
||||
notify_display_update()
|
||||
update_lcd_cloud_alert()
|
||||
|
||||
# Global state trackers
|
||||
microwave_states = {"2": MicrowaveState.IDLE}
|
||||
microwave_states = {"2": MicrowaveState("2", on_change_callback=on_microwave_change)}
|
||||
button_state = False
|
||||
cloud_alert = False # Global status flag for screen / UI display
|
||||
cloud_alert = None # Global status flag for screen / UI display
|
||||
async_event_queue = None
|
||||
|
||||
# Display Manager instance
|
||||
display_manager = DisplayManager(endpoint_path="/api/display")
|
||||
rgb_lcd_manager = RGBLCDManager(rgb_lcd, brightness=0.8, interval=5.0)
|
||||
|
||||
# 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 ---
|
||||
# --- HARDWARE & MQTT SETUP ---
|
||||
lora = get_lora()
|
||||
lora.configure()
|
||||
|
||||
# Local MQTT Broker (ESP32 IR & Sensors)
|
||||
mqtt_client = get_mqtt_client(
|
||||
host="192.168.50.1",
|
||||
client_id="smartwave-orchestrateur-" + DEVICE_ID,
|
||||
@@ -86,11 +159,39 @@ 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)
|
||||
|
||||
# RFID reader
|
||||
rfid = rfid_reader.RFIDReader(rx_pin=17)
|
||||
|
||||
# DHT sensor warmup
|
||||
time.sleep(2) # Allow GrovePi MCU and DHT sensor circuits to settle post-service start
|
||||
temp_hum.get_temperature_and_humidity() # Dummy read to flush initial NaN state
|
||||
|
||||
if hasattr(mqtt_client._client, "loop_start"):
|
||||
mqtt_client._client.loop_start()
|
||||
print("[MQTT] Paho background loop started.")
|
||||
print("[MQTT Local] Paho background loop started.")
|
||||
|
||||
# --- BACKGROUND TASKS (PRODUCERS) ---
|
||||
# Cloud MQTT Broker (Remote Commands)
|
||||
cloud_mqtt_client = get_mqtt_client(
|
||||
host="mqtt.matthiasg.dev",
|
||||
client_id=DEVICE_ID,
|
||||
use_tls=False,
|
||||
username="microwave_device",
|
||||
password="myMicrowaveVerySecret",
|
||||
keepalive=config.MQTT_KEEPALIVE,
|
||||
)
|
||||
try:
|
||||
cloud_mqtt_client.connect()
|
||||
cloud_mqtt_client.subscribe("cmd/all", qos=config.CLOUD_MQTT_QOS)
|
||||
cloud_mqtt_client.subscribe(f"cmd/{DEVICE_ID}", qos=config.CLOUD_MQTT_QOS)
|
||||
except Exception as e:
|
||||
print(f"[MQTT Cloud] Error connecting to cloud MQTT broker: {e}")
|
||||
|
||||
if hasattr(cloud_mqtt_client._client, "loop_start"):
|
||||
cloud_mqtt_client._client.loop_start()
|
||||
print("[MQTT Cloud] Paho background loop started.")
|
||||
|
||||
|
||||
# --- BACKGROUND TASKS ---
|
||||
async def lora_listener_task():
|
||||
"""Polls LoRa and pushes to the async queue."""
|
||||
print("[LoRa] Async listener started.")
|
||||
@@ -102,8 +203,8 @@ async def lora_listener_task():
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
async def mqtt_listener_task():
|
||||
"""Polls MQTT cache and pushes to the async queue."""
|
||||
print("[MQTT] Async listener started.")
|
||||
"""Polls local MQTT cache and pushes to the async queue."""
|
||||
print("[MQTT Local] Async listener started.")
|
||||
while True:
|
||||
message = mqtt_client.get_message()
|
||||
if message:
|
||||
@@ -124,19 +225,264 @@ async def mqtt_listener_task():
|
||||
})
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
async def cloud_mqtt_listener_task():
|
||||
"""Polls Cloud MQTT cache and pushes commands to the async queue."""
|
||||
print("[MQTT Cloud] Async listener started.")
|
||||
while True:
|
||||
message = cloud_mqtt_client.get_message()
|
||||
if message:
|
||||
try:
|
||||
payload = json.loads(message['payload'])
|
||||
except Exception:
|
||||
payload = message['payload']
|
||||
|
||||
topic = message['topic']
|
||||
if isinstance(topic, bytes):
|
||||
topic = topic.decode('utf-8')
|
||||
|
||||
await async_event_queue.put({
|
||||
"source": "CloudMQTT",
|
||||
"topic": topic,
|
||||
"data": payload
|
||||
})
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
async def rfid_listener_task():
|
||||
"""Polls RFID reader, verifies cards via OData API, and toggles TECHNICIAN_MODE."""
|
||||
global TECHNICIAN_MODE
|
||||
print("[RFID] Async task started on GPIO 17.")
|
||||
|
||||
last_scanned_tag = None
|
||||
last_scan_time = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Quick non-blocking read via pigpio IPC
|
||||
tag_id = await asyncio.to_thread(rfid.read_tag)
|
||||
current_time = time.time()
|
||||
|
||||
if tag_id and tag_id != "0000000000":
|
||||
# Debounce same tag within 3 seconds to avoid spamming the API
|
||||
if tag_id == last_scanned_tag and (current_time - last_scan_time) < 3.0:
|
||||
await asyncio.sleep(0.1)
|
||||
continue
|
||||
|
||||
last_scanned_tag = tag_id
|
||||
last_scan_time = current_time
|
||||
print(f"[RFID] Tag detected: {tag_id}. Verifying with API...")
|
||||
|
||||
is_valid = await check_rfid_card_api(tag_id)
|
||||
|
||||
if is_valid:
|
||||
TECHNICIAN_MODE = True
|
||||
print(f"[RFID] Card {tag_id} VALIDATED! -> TECHNICIAN_MODE = TRUE")
|
||||
# Optional: Sound positive feedback beep
|
||||
buzzer.buzzer_siren(beats_nb=2)
|
||||
else:
|
||||
print(f"[RFID] Card {tag_id} REJECTED / Invalid.")
|
||||
# Optional: Sound error feedback beep
|
||||
buzzer.buzzer_siren(beats_nb=4)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[RFID Task] Error reading RFID tag: {e}")
|
||||
|
||||
# Sleep 100ms between checks to keep CPU usage near zero
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
async def display_broadcast_worker_task():
|
||||
"""
|
||||
Debounced display broadcast task.
|
||||
Waits until DISPLAY_DEBOUNCE_SECONDS have passed since the first queued update
|
||||
before sending state to all connected screens.
|
||||
"""
|
||||
global display_update_queue, microwave_states, cloud_alert
|
||||
|
||||
print("[Display Worker] Started debounced display broadcast worker.")
|
||||
while True:
|
||||
# Block until the FIRST update reminder arrives in the queue
|
||||
first_event_time = await display_update_queue.get()
|
||||
|
||||
# Compute wait time relative to the first event's timestamp
|
||||
elapsed = time.time() - first_event_time
|
||||
remaining_wait = DISPLAY_DEBOUNCE_SECONDS - elapsed
|
||||
if remaining_wait > 0:
|
||||
await asyncio.sleep(remaining_wait)
|
||||
|
||||
# Clear any subsequent events that arrived during the wait period
|
||||
while not display_update_queue.empty():
|
||||
try:
|
||||
display_update_queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
|
||||
# Broadcast state ONCE
|
||||
await display_manager.broadcast_state(
|
||||
microwave_states=microwave_states,
|
||||
cloud_alert=cloud_alert
|
||||
)
|
||||
|
||||
async def process_messages_task():
|
||||
"""Consumes the unified event queue."""
|
||||
# Helper to normalize config topics to str
|
||||
def to_str(val):
|
||||
return val.decode('utf-8') if isinstance(val, bytes) else val
|
||||
|
||||
while True:
|
||||
msg = await async_event_queue.get()
|
||||
source = msg["source"]
|
||||
data = msg["data"]
|
||||
|
||||
if source == "LoRa":
|
||||
if "action" in data.get("data", {}):
|
||||
if type(data["data"]) != dict:
|
||||
print(f"[LoRa] Invalid data format received: {data['data']}")
|
||||
continue
|
||||
action = data["data"]["action"]
|
||||
if action == LoraCommands.COOKING_UPDATE:
|
||||
mw_id = data["data"].get("id")
|
||||
if mw_id in microwave_states:
|
||||
state_info = data["data"]
|
||||
microwave_states[mw_id].set_cooking_state(state_info.get("cooking_state", microwave_states[mw_id].cooking_state))
|
||||
microwave_states[mw_id].set_cooking_estimated_remaining_time(state_info.get("estimated_remaining_time", microwave_states[mw_id].cooking_estimated_remaining_time))
|
||||
microwave_states[mw_id].set_paused(state_info.get("paused", microwave_states[mw_id].paused))
|
||||
elif action == LoraCommands.NEW_ALERT:
|
||||
alert_type = data["data"].get("alert_type")
|
||||
alert_message = data["data"].get("alert_message")
|
||||
alert = Alert(alert_type, alert_message, redistributed=True)
|
||||
alert_manager.add_alert(alert)
|
||||
print(f"[LoRa] New alert received: {alert.to_dict()}")
|
||||
else:
|
||||
print(f"[LoRa] Unhandled action received: {action} with data: {data['data']}")
|
||||
elif source == "MQTT":
|
||||
topic = msg["topic"]
|
||||
|
||||
hello_topic = to_str(config.MQTT_TOPIC_HELLO)
|
||||
sensor_topic = to_str(config.MQTT_TOPIC_SENSOR)
|
||||
|
||||
if topic == hello_topic:
|
||||
if data.get("id_orchestrator") != DEVICE_ID:
|
||||
component_id = data.get("id_microwave")
|
||||
component_type = data.get("type", deviceTypes.DEVICE_TYPES["MICROWAVE"])
|
||||
|
||||
if component_id:
|
||||
print(f"[MQTT] Hello received from '{component_id}' ({component_type}). Updating DB & sending ACK.")
|
||||
# Offload DB insertion to async thread execution pool
|
||||
await asyncio.to_thread(save_connected_component, component_id, component_type)
|
||||
|
||||
# Pass change callback to newly connected microwave
|
||||
microwave_states[component_id] = MicrowaveState(
|
||||
component_id,
|
||||
on_change_callback=on_microwave_change
|
||||
)
|
||||
notify_display_update() # Update the external screens
|
||||
update_lcd_microwave_count() # Update the LCD with new microwave count
|
||||
|
||||
mqtt_client.publish(
|
||||
config.MQTT_TOPIC_HELLO,
|
||||
payloads.mqtt_hello_ack(DEVICE_ID, component_id),
|
||||
qos=config.MQTT_QOS
|
||||
)
|
||||
|
||||
elif topic == sensor_topic:
|
||||
mw_id = str(data.get("id_microwave"))
|
||||
print(f"[MQTT] Sensor data received for microwave {mw_id}: {data}")
|
||||
|
||||
# Store IR data and notify the waiting dish handler
|
||||
ir_data_cache[mw_id] = data
|
||||
if mw_id in ir_data_events:
|
||||
ir_data_events[mw_id].set()
|
||||
|
||||
elif source == "CloudMQTT":
|
||||
topic = msg["topic"]
|
||||
print(f"[CloudMQTT] Command received on '{topic}': {data}")
|
||||
|
||||
action = data.get("action")
|
||||
|
||||
if action == "request_telemetry":
|
||||
endpoint = data.get("endpoint")
|
||||
if endpoint:
|
||||
# Schedule as a background task so processing the queue isn't stalled
|
||||
do_timeout = topic == "cmd/all"
|
||||
asyncio.create_task(handle_telemetry_request(endpoint, do_timeout=do_timeout))
|
||||
else:
|
||||
print("[CloudMQTT] Received 'request_telemetry' but missing 'endpoint' field.")
|
||||
else:
|
||||
print(f"[CloudMQTT] Unknown action: {action}")
|
||||
|
||||
# === LCD DISPLAY ===
|
||||
def on_screens_change(screens, is_removed: bool, modified_name: str):
|
||||
"""Callback for when screens are added or removed."""
|
||||
log(f"[DisplayManager] Screens changed. Current screens: {list(screens.keys())}")
|
||||
rgb_lcd_manager.set_external_screen_count(len(screens))
|
||||
|
||||
# Add Or remove from database of connected components
|
||||
# 1. Extract id from name (assuming format "smartwave-epaper-<id>._displaytcp._tcp.local")
|
||||
display_id = modified_name.split("-")[-1].split(".")[0]
|
||||
if is_removed:
|
||||
remove_connected_component(display_id)
|
||||
else:
|
||||
save_connected_component(display_id, "external_display")
|
||||
|
||||
display_manager.set_on_screens_change_callback(on_screens_change)
|
||||
|
||||
def update_lcd_microwave_count():
|
||||
"""Updates the LCD with the current number of connected microwaves."""
|
||||
global microwave_states
|
||||
count = len(microwave_states)
|
||||
rgb_lcd_manager.set_microwave_count(count)
|
||||
|
||||
def update_lcd_cloud_alert():
|
||||
"""Updates the LCD with the current cloud connectivity status."""
|
||||
global cloud_alert
|
||||
rgb_lcd_manager.set_cloud_alert(cloud_alert)
|
||||
|
||||
# --- Button ---
|
||||
def button_callback():
|
||||
"""Button physical interrupt callback."""
|
||||
global button_state
|
||||
if microwave_states.get("2") == MicrowaveState.COOKING:
|
||||
if microwave_states.get("2").cooking_state in (CookingStates.COOKING, CookingStates.DONE, CookingStates.STIRRING_REQUIRED, CookingStates.ALERT):
|
||||
print("[Button] Toggling pause/resume for microwave '2'.")
|
||||
lora.send_reliable({"id": DEVICE_ID, "microwave_id": "2", "action": LoraCommands.TOGGLE_PAUSE})
|
||||
lora.send_reliable({"id": DEVICE_ID, "microwave_id": "2", "action": LoraCommands.TOGGLE_PAUSE}, max_retries=6)
|
||||
else:
|
||||
button_state = not button_state
|
||||
print(f"[Button] Defrost state toggled to: {button_state}")
|
||||
lora.send_reliable({"id": DEVICE_ID, "microwave_id": "2", "action": LoraCommands.TOGGLE_DEFROST, "defrost_state": button_state}, max_retries=5)
|
||||
|
||||
button.set_callback(button_callback)
|
||||
button.start_button_monitoring_thread()
|
||||
|
||||
# --- Alert manager ---
|
||||
alert_manager = None
|
||||
|
||||
async def on_new_alert(alert: Alert):
|
||||
# Sends to cloud server
|
||||
alert_dict = alert.to_dict()
|
||||
|
||||
logs_list = await get_systemd_logs(lines=200, service_name="smartwave")
|
||||
|
||||
payload = {
|
||||
"orchestrator_id": DEVICE_ID,
|
||||
"alert": alert_dict,
|
||||
"logs": logs_list
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
"https://smartwave.matthiasg.dev/alert",
|
||||
headers={"Content-Type": "application/json", "X-API-Key": API_KEY},
|
||||
json=payload,
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
res_json = response.json()
|
||||
log(f"[Alert Handling] Alert sent to cloud: {alert_dict}, Response: {res_json}")
|
||||
|
||||
if not alert.redistributed: # If not redistributed from microwaves
|
||||
# Send the alert to the lora device
|
||||
payload = payloads.lora_new_alert(alert)
|
||||
print(f"[Alert Handling] Sending alert to LoRa device: {payload}")
|
||||
if lora.send_reliable(payload, max_retries=7):
|
||||
microwave_states["2"].set_cooking_state(CookingStates.ALERT)
|
||||
|
||||
# --- HARDWARE CONTROLLERS ---
|
||||
def _stop_hardware(microwave_id: str):
|
||||
print(f"[{microwave_id}] /!\\ Emergency stop issued to hardware.")
|
||||
@@ -158,6 +504,16 @@ def read_local_sensors(microwave_id, initial_dish_height):
|
||||
if temp is not None:
|
||||
sensor_data["temperature"] = temp
|
||||
sensor_data["humidity"] = hum
|
||||
if config.DEBUG_TEMPERATURE_ALERT or temp > config.MAX_ALLOWED_TEMPERATURE_TECHNICAL_COMPARTMENT:
|
||||
print(f"[{microwave_id}] ALERT: Unsafe temperature in technical compartment detected! Temp: {temp}°C, Humidity: {hum}%")
|
||||
alert = Alert(
|
||||
AlertType.TEMPERATURE_ALERT,
|
||||
f"Unsafe temperature in technical compartment detected! Temp: {temp}/{config.MAX_ALLOWED_TEMPERATURE_COOKING_COMPARTMENT}°C, Humidity: {hum}%",
|
||||
redistributed=False
|
||||
)
|
||||
# lora.send_reliable(payloads.lora_new_alert(alert), max_retries=5)
|
||||
alert_manager.add_alert(alert)
|
||||
return None # Abort further processing if unsafe temperature detected
|
||||
except Exception as e:
|
||||
log(f"[{microwave_id}] DHT read warning: {e}")
|
||||
|
||||
@@ -172,7 +528,7 @@ def read_local_sensors(microwave_id, initial_dish_height):
|
||||
|
||||
async def handle_new_dish(microwave_id, detected_height):
|
||||
"""Triggered when a new dish is placed inside."""
|
||||
microwave_states[microwave_id] = MicrowaveState.ANALYZING
|
||||
microwave_states[microwave_id].set_state(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
|
||||
@@ -192,34 +548,78 @@ async def handle_new_dish(microwave_id, detected_height):
|
||||
|
||||
# 4. Wait for local sensors to finish reading
|
||||
sensors_data = await sensor_task
|
||||
|
||||
if sensors_data is None:
|
||||
print(f"[{microwave_id}] Aborting cooking plan because of empty sensor data")
|
||||
microwave_states[microwave_id].set_state(MicrowaveState.ALERT)
|
||||
return
|
||||
|
||||
# Check if dish was removed while reading sensors
|
||||
if microwave_states.get(microwave_id) != MicrowaveState.ANALYZING:
|
||||
if microwave_states.get(microwave_id).state != 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)
|
||||
# 5. Wait for MQTT IR data
|
||||
try:
|
||||
await asyncio.wait_for(event.wait(), timeout=10.0)
|
||||
ir_payload = ir_data_cache.get(microwave_id, {})
|
||||
sensors_data["ir_initial_temp"] = ir_payload.get("dish_temp")
|
||||
sensors_data["ir_ambient_temp"] = ir_payload.get("ambient_temp")
|
||||
print(f"[{microwave_id}] IR data synchronized successfully: {ir_payload}")
|
||||
# 6. Dispatch cloud request task
|
||||
asyncio.create_task(request_cloud_cooking_plan(microwave_id, sensors_data))
|
||||
except asyncio.TimeoutError:
|
||||
print(f"[{microwave_id}] ⚠️ Timeout waiting for MQTT IR data from ESP32.")
|
||||
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))
|
||||
# === API CALLS ===
|
||||
|
||||
async def check_rfid_card_api(card_id: str) -> bool:
|
||||
"""Queries the OData API asynchronously to verify if card_id is valid."""
|
||||
url = "https://smartwave.matthiasg.dev/rfid-card"
|
||||
|
||||
params = {
|
||||
"$filter": f"card_id eq '{card_id}'"
|
||||
}
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if API_KEY:
|
||||
headers["X-API-Key"] = API_KEY
|
||||
|
||||
try:
|
||||
response = await asyncio.to_thread(
|
||||
requests.get,
|
||||
url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=5
|
||||
)
|
||||
if response.status_code == 200:
|
||||
set_cloud_alert(False) # Clear cloud alert on success
|
||||
data = response.json()
|
||||
|
||||
if isinstance(data, dict):
|
||||
items = data.get("value", data.get("data", []))
|
||||
return len(items) > 0 if isinstance(items, list) else bool(data)
|
||||
elif isinstance(data, list):
|
||||
return len(data) > 0
|
||||
else:
|
||||
print(f"[RFID Task] OData API returned status {response.status_code}: {response.json()}")
|
||||
set_cloud_alert(True) # Set cloud alert on API connection failure
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"[RFID Task] OData API check error: {e}")
|
||||
set_cloud_alert(True) # Set cloud alert on API connection failure
|
||||
return False
|
||||
|
||||
async def request_cloud_cooking_plan(microwave_id, sensors_data):
|
||||
"""Sends all data to the cloud with up to 3 retries (30s interval)."""
|
||||
global cloud_alert
|
||||
microwave_states[microwave_id] = MicrowaveState.WAITING_FOR_CLOUD
|
||||
"""Sends all data to the cloud with up to 3 retries (330s timeout for AI generation)."""
|
||||
global cloud_alert, alert_manager
|
||||
microwave_states[microwave_id].set_state(MicrowaveState.WAITING_FOR_CLOUD)
|
||||
URL = "https://smartwave.matthiasg.dev/cooking-params"
|
||||
|
||||
# Format image
|
||||
@@ -229,114 +629,159 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
|
||||
print(f"[{microwave_id}] Requesting cooking plan from cloud app...")
|
||||
|
||||
max_retries = 3
|
||||
retry_delay_seconds = 30
|
||||
retry_delay_seconds = 10 # Reduced delay since each request already waits up to 5 mins
|
||||
|
||||
# Tuple format: (connect_timeout_seconds, read_timeout_seconds)
|
||||
HTTP_TIMEOUT = (10, 330)
|
||||
|
||||
for attempt in range(1, max_retries + 1):
|
||||
if microwave_states.get(microwave_id) != MicrowaveState.WAITING_FOR_CLOUD:
|
||||
if microwave_states.get(microwave_id).state != MicrowaveState.WAITING_FOR_CLOUD:
|
||||
print(f"[{microwave_id}] Dish removed or state changed. Aborting API request.")
|
||||
microwave_states[microwave_id].set_state(MicrowaveState.IDLE)
|
||||
return
|
||||
|
||||
try:
|
||||
print(f"[{microwave_id}] Connection attempt {attempt}/{max_retries}...")
|
||||
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.get(microwave_id) != MicrowaveState.WAITING_FOR_CLOUD:
|
||||
print(f"[{microwave_id}] Connection attempt {attempt}/{max_retries} (Waiting up to 5.5 min)...")
|
||||
|
||||
response = await asyncio.to_thread(
|
||||
requests.post,
|
||||
URL,
|
||||
json=sensors_data,
|
||||
headers={"Content-Type": "application/json", "X-API-Key": API_KEY},
|
||||
timeout=HTTP_TIMEOUT
|
||||
)
|
||||
|
||||
# Abort if state changed while waiting for cloud AI response
|
||||
if microwave_states.get(microwave_id).state != MicrowaveState.WAITING_FOR_CLOUD:
|
||||
print(f"[{microwave_id}] Dish removed during API request. Discarding API plan.")
|
||||
microwave_states[microwave_id].set_state(MicrowaveState.IDLE)
|
||||
return
|
||||
|
||||
response.raise_for_status()
|
||||
plan = response.json().get("cook_plan", {})
|
||||
|
||||
set_cloud_alert(False) # Clear any previous cloud alert
|
||||
|
||||
# JSON extraction
|
||||
res_json = response.json()
|
||||
|
||||
# Error handling
|
||||
if "error" in res_json:
|
||||
print(f"[{microwave_id}] Cloud API returned error: {res_json['error']}")
|
||||
microwave_states[microwave_id].set_state(MicrowaveState.DONE)
|
||||
|
||||
if not res_json['is_safe']: # The cooking area is not safe, raise an alert
|
||||
alert = Alert(AlertType.COOKING_SAFETY, res_json['warning_message'], redistributed=False)
|
||||
alert_manager.add_alert(alert)
|
||||
else:
|
||||
alert = Alert(AlertType.ERROR, f"Cloud API error for microwave {microwave_id}: {res_json['error']}")
|
||||
return
|
||||
|
||||
|
||||
plan = res_json.get("plan", {}).get("cook_plan", res_json)
|
||||
|
||||
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
|
||||
print(f"[{microwave_id}] Invalid plan received: {res_json}")
|
||||
microwave_states[microwave_id].set_state(MicrowaveState.DONE)
|
||||
return
|
||||
|
||||
print(f"[{microwave_id}] Cloud Plan Received! Starting microwave: {c_time}s @ {c_power}W")
|
||||
cloud_alert = False # Reset alert flag on success
|
||||
microwave_states[microwave_id] = MicrowaveState.COOKING
|
||||
if config.DEBUG:
|
||||
c_time = min(c_time, 10) # Limit to 10s for debug
|
||||
c_temp = min(c_temp, 50) # Limit to 50°C for debug
|
||||
microwave_states[microwave_id].set_state(MicrowaveState.COOKING)
|
||||
|
||||
if config.DEBUG_SHORTER_COOKING_PLAN:
|
||||
c_time = 20 # Set to 20s for debug
|
||||
c_temp = 50 # Set to 50°C for debug
|
||||
|
||||
mqtt_client.publish(
|
||||
config.MQTT_TOPIC_COOKING,
|
||||
payloads.mqtt_cooking_config(microwave_id, c_time, c_power, c_temp),
|
||||
qos=config.MQTT_QOS
|
||||
)
|
||||
|
||||
microwave_states[microwave_id].set_cooking_start_time(time.time())
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
# Print stacktrace
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
print(f"[{microwave_id}] Cloud API Error (Attempt {attempt}/{max_retries}): {e}")
|
||||
|
||||
if attempt < max_retries:
|
||||
print(f"[{microwave_id}] Retrying in {retry_delay_seconds} seconds...")
|
||||
# Interruptible wait loop in case user removes the dish mid-wait
|
||||
# Interruptible wait loop in case state changes mid-wait
|
||||
for _ in range(retry_delay_seconds):
|
||||
if microwave_states.get(microwave_id) != MicrowaveState.WAITING_FOR_CLOUD:
|
||||
if microwave_states.get(microwave_id).state != MicrowaveState.WAITING_FOR_CLOUD:
|
||||
print(f"[{microwave_id}] State changed during retry wait. Aborting retries.")
|
||||
return
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Executed only if all 3 retries failed
|
||||
print(f"[{microwave_id}] All 3 cloud retries failed. Setting global alert flag.")
|
||||
cloud_alert = True
|
||||
microwave_states[microwave_id] = MicrowaveState.DONE
|
||||
print(f"[{microwave_id}] All cloud retries failed. Setting global alert flag.")
|
||||
set_cloud_alert(True)
|
||||
microwave_states[microwave_id].set_state(MicrowaveState.DONE)
|
||||
|
||||
# --- MAIN LOGIC TASKS ---
|
||||
async def process_messages_task():
|
||||
"""Consumes the unified event queue."""
|
||||
while True:
|
||||
msg = await async_event_queue.get()
|
||||
source = msg["source"]
|
||||
data = msg["data"]
|
||||
|
||||
if source == "LoRa":
|
||||
if "new_cooking_state" in data.get("data", {}):
|
||||
mw_id = data["data"].get("id")
|
||||
n_state = data["data"].get("new_cooking_state")
|
||||
print(f"[LoRa] Microwave {mw_id} state changed to: {n_state}")
|
||||
|
||||
if n_state == CookingStates.IDLE and microwave_states.get(mw_id) == MicrowaveState.COOKING:
|
||||
microwave_states[mw_id] = MicrowaveState.DONE
|
||||
print(f"[{mw_id}] Cooking finished. Waiting for user to remove dish.")
|
||||
|
||||
elif source == "MQTT":
|
||||
topic = msg["topic"]
|
||||
|
||||
# Helper to normalize config topics to str
|
||||
def to_str(val):
|
||||
return val.decode('utf-8') if isinstance(val, bytes) else val
|
||||
|
||||
hello_topic = to_str(config.MQTT_TOPIC_HELLO)
|
||||
sensor_topic = to_str(config.MQTT_TOPIC_SENSOR)
|
||||
|
||||
if topic == hello_topic:
|
||||
if data.get("id_orchestrator") != DEVICE_ID:
|
||||
component_id = data.get("id_microwave")
|
||||
component_type = data.get("type", deviceTypes.DEVICE_TYPES["MICROWAVE"])
|
||||
|
||||
if component_id:
|
||||
print(f"[MQTT] Hello received from '{component_id}' ({component_type}). Updating DB & sending ACK.")
|
||||
# Offload DB insertion to async thread execution pool
|
||||
await asyncio.to_thread(save_connected_component, component_id, component_type)
|
||||
|
||||
mqtt_client.publish(
|
||||
config.MQTT_TOPIC_HELLO,
|
||||
payloads.mqtt_hello_ack(DEVICE_ID, component_id),
|
||||
qos=config.MQTT_QOS
|
||||
)
|
||||
|
||||
elif topic == sensor_topic:
|
||||
mw_id = str(data.get("id_microwave"))
|
||||
print(f"[MQTT] Sensor data received for microwave {mw_id}: {data}")
|
||||
async def handle_telemetry_request(endpoint: str, do_timeout=True):
|
||||
"""
|
||||
Task to gather telemetry data (including structured systemd logs)
|
||||
and post it to the target HTTP endpoint.
|
||||
"""
|
||||
print(f"[Telemetry] Starting telemetry collection for endpoint: {endpoint}")
|
||||
try:
|
||||
# 1. Fetch systemd logs asynchronously
|
||||
logs_list = await get_systemd_logs(lines=200, service_name="smartwave")
|
||||
|
||||
# 2. Unpack temperature and humidity
|
||||
ambient_temp, ambient_humidity = temp_hum.get_temperature_and_humidity_with_retry()
|
||||
|
||||
# Unpack microwave states
|
||||
microwave_states_snapshot = {mw_id: mw_state.to_dict() for mw_id, mw_state in microwave_states.items()}
|
||||
|
||||
# 3. Build the telemetry payload (returns a JSON string)
|
||||
telemetry_payload = payloads.telemetry_payload(
|
||||
device_id=DEVICE_ID,
|
||||
microwave_states=microwave_states_snapshot,
|
||||
button_state=button_state,
|
||||
cloud_alert=cloud_alert,
|
||||
gps_data=gps.get_gps_data(),
|
||||
ambient_temp=ambient_temp,
|
||||
ambient_humidity=ambient_humidity,
|
||||
connected_components=get_connected_components(),
|
||||
logs=logs_list
|
||||
)
|
||||
|
||||
# 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()
|
||||
# 4. Wait for he microwave turn to send the telemetry data to the cloud endpoint
|
||||
if do_timeout:
|
||||
do_timeout = int(DEVICE_ID.split("_")[-1]) * config.TELEMETRY_SEND_INTERVAL
|
||||
print(f"[Telemetry] Waiting for {do_timeout}s before sending telemetry to avoid collisions...")
|
||||
await asyncio.sleep(do_timeout)
|
||||
|
||||
# 5. Offload blocking HTTP POST to thread pool
|
||||
print(f"[Telemetry] Sending payload with {len(logs_list)} log entries...")
|
||||
response = await asyncio.to_thread(
|
||||
requests.post,
|
||||
endpoint,
|
||||
data=telemetry_payload, # <-- Changed from json=telemetry_payload
|
||||
headers={"Content-Type": "application/json", "X-API-Key": API_KEY},
|
||||
timeout=15
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
set_cloud_alert(False) # Clear any previous cloud alert on success
|
||||
|
||||
print(f"[Telemetry] Successfully dispatched telemetry (HTTP {response.status_code}).")
|
||||
|
||||
except requests.exceptions.RequestException as req_err:
|
||||
print(f"[Telemetry] HTTP Request failed to {endpoint}: {req_err}")
|
||||
set_cloud_alert(True) # Set cloud alert on HTTP failure
|
||||
except Exception as e:
|
||||
print(f"[Telemetry] Error handling telemetry request: {e}")
|
||||
|
||||
async def get_filtered_dish_height(samples=3, delay=0.04):
|
||||
"""Reads ultrasonic sensor multiple times and returns the median, discarding invalid zeros."""
|
||||
@@ -364,20 +809,23 @@ async def monitor_dish_height_task():
|
||||
|
||||
while True:
|
||||
dist = await get_filtered_dish_height()
|
||||
current_state = microwave_states.get(mw_id, MicrowaveState.IDLE)
|
||||
|
||||
try:
|
||||
current_state = microwave_states.get(mw_id).state
|
||||
except AttributeError:
|
||||
current_state = 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:
|
||||
# - Must be > 1.5 cm to detect dish insertion
|
||||
# - Must be < 0.8 cm to detect dish removal
|
||||
if dist > 2.0:
|
||||
consecutive_present += 1
|
||||
consecutive_absent = 0
|
||||
elif dist < 1.2:
|
||||
elif dist < 1.0:
|
||||
consecutive_absent += 1
|
||||
consecutive_present = 0
|
||||
else:
|
||||
# Dead-zone (1.2cm to 2.5cm) -> Noise buffer
|
||||
# Dead-zone (2.0cm to 1.0cm) -> Noise buffer
|
||||
consecutive_present = 0
|
||||
consecutive_absent = 0
|
||||
|
||||
@@ -390,8 +838,8 @@ async def monitor_dish_height_task():
|
||||
elif consecutive_absent >= REQUIRED_STABLE_READS and current_state != MicrowaveState.IDLE:
|
||||
consecutive_absent = 0
|
||||
print(f"\n[{mw_id}] Dish Removed! Resetting state to IDLE.")
|
||||
microwave_states[mw_id] = MicrowaveState.IDLE
|
||||
cloud_alert = False # Reset error alert on dish removal
|
||||
microwave_states[mw_id].set_state(MicrowaveState.IDLE)
|
||||
set_cloud_alert(False)
|
||||
|
||||
if current_state == MicrowaveState.COOKING:
|
||||
_stop_hardware(mw_id)
|
||||
@@ -399,23 +847,44 @@ async def monitor_dish_height_task():
|
||||
ir_data_cache.pop(mw_id, None)
|
||||
ir_data_events.pop(mw_id, None)
|
||||
|
||||
await asyncio.sleep(0.3)
|
||||
await asyncio.sleep(0.7)
|
||||
|
||||
# --- BOOTSTRAP ---
|
||||
async def main():
|
||||
global async_event_queue
|
||||
print("🚀 Orchestrateur Asyncio prêt. Lancement des tâches...")
|
||||
global async_event_queue, display_update_queue
|
||||
print("Orchestrateur prêt. Lancement des tâches...")
|
||||
|
||||
# Initialize the alert manager and set the callback
|
||||
global alert_manager
|
||||
alert_manager = AlertManager()
|
||||
alert_manager.set_on_alert_callback(on_new_alert)
|
||||
|
||||
# Initialize SQLite database table
|
||||
init_db()
|
||||
|
||||
# Start mDNS Display discovery
|
||||
await display_manager.start()
|
||||
|
||||
save_connected_component(DEVICE_ID, "orchestrateur")
|
||||
|
||||
async_event_queue = asyncio.Queue()
|
||||
display_update_queue = asyncio.Queue()
|
||||
|
||||
await asyncio.gather(
|
||||
lora_listener_task(),
|
||||
mqtt_listener_task(),
|
||||
cloud_mqtt_listener_task(),
|
||||
process_messages_task(),
|
||||
monitor_dish_height_task()
|
||||
monitor_dish_height_task(),
|
||||
display_broadcast_worker_task(),
|
||||
rfid_listener_task(),
|
||||
start_technician_web_server(
|
||||
get_logs_cb=get_systemd_logs,
|
||||
telemetry_cb=handle_telemetry_request,
|
||||
is_tech_mode_cb=is_technician_mode_active,
|
||||
host=config.TECHNICIAN_WEB_SERVER_HOST,
|
||||
port=config.TECHNICIAN_WEB_SERVER_PORT
|
||||
)
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -424,6 +893,11 @@ if __name__ == "__main__":
|
||||
except KeyboardInterrupt:
|
||||
print("\nArrêt manuel.")
|
||||
finally:
|
||||
rfid.close() # <-- ADDED CLEANUP HERE
|
||||
asyncio.run(display_manager.stop())
|
||||
if hasattr(mqtt_client._client, "loop_stop"):
|
||||
mqtt_client._client.loop_stop()
|
||||
mqtt_client.close()
|
||||
if hasattr(cloud_mqtt_client._client, "loop_stop"):
|
||||
cloud_mqtt_client._client.loop_stop()
|
||||
mqtt_client.close()
|
||||
cloud_mqtt_client.close()
|
||||
@@ -9,4 +9,10 @@ log_type information
|
||||
allow_anonymous true
|
||||
|
||||
listener 8884
|
||||
protocol mqtt
|
||||
protocol mqtt
|
||||
|
||||
# Unencrypted listener for local Thread nodes
|
||||
listener 1883
|
||||
protocol mqtt
|
||||
socket_domain ipv6
|
||||
allow_anonymous true
|
||||
@@ -14,4 +14,11 @@ cafile /mosquitto/certs/ca.crt
|
||||
certfile /mosquitto/certs/server.crt
|
||||
keyfile /mosquitto/certs/server.key
|
||||
require_certificate false
|
||||
tls_version tlsv1.2
|
||||
tls_version tlsv1.2
|
||||
|
||||
# Unencrypted listener for local Thread nodes
|
||||
# Thread is already encrypted, so we don't need to use TLS for this listener
|
||||
listener 1883 ::
|
||||
protocol mqtt
|
||||
socket_domain ipv6
|
||||
allow_anonymous true
|
||||
@@ -1,6 +1,16 @@
|
||||
paho-mqtt>=1.6,<3
|
||||
pyserial>=3.5,<4
|
||||
zeroconf>=0.131.0
|
||||
fastapi>=0.100.0,<1.0.0
|
||||
uvicorn>=0.20.0,<1.0.0
|
||||
python-multipart>=0.0.6,<1.0.0
|
||||
requests==2.34.2
|
||||
numpy>=1.20.0
|
||||
# picamera2>=0.3.36,<4 # → Installed with apt install python3-picamera2
|
||||
# OpenCV
|
||||
# sudo apt install -y python3-opencv
|
||||
# sudo apt install -y opencv-data
|
||||
# sudo apt install -y opencv-data
|
||||
# sudo apt install -y swig python3-dev liblgpio-dev
|
||||
# ... Replaces this ?
|
||||
# sudo apt install pigpio python3-pigpio
|
||||
# sudo systemctl enable pigpiod --now
|
||||
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
Manages the RGB LCD display with the current state of the microwaves and cloud connectivity.
|
||||
|
||||
The display switches content every x seconds between microwaves, external screens,
|
||||
and cloud connectivity state using a background thread.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
SCREEN_SWITCH_INTERVAL = 5 # seconds
|
||||
DEFAULT_BRIGHTNESS = 0.3 # Constant brightness (range 0.0 to 1.0)
|
||||
|
||||
|
||||
class RGBLCDManager:
|
||||
def __init__(self, rgb_lcd, interval=SCREEN_SWITCH_INTERVAL, brightness=DEFAULT_BRIGHTNESS, auto_start=True):
|
||||
self.rgb_lcd = rgb_lcd
|
||||
self.interval = interval
|
||||
self.brightness = max(0.0, min(1.0, float(brightness)))
|
||||
|
||||
# Internal state store
|
||||
self._microwave_count = "N/A"
|
||||
self._external_screen_count = "N/A"
|
||||
self._cloud_alert = None
|
||||
|
||||
# Screen rotation sequence
|
||||
self.screens = [
|
||||
self.display_microwave_state,
|
||||
self.display_external_screens,
|
||||
self.display_cloud_connectivity,
|
||||
]
|
||||
self.current_screen_idx = 0
|
||||
|
||||
# Threading controls
|
||||
self._thread = None
|
||||
self._running = False
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
if auto_start:
|
||||
self.start()
|
||||
|
||||
# --- SETTER METHODS ---
|
||||
|
||||
def set_brightness(self, brightness):
|
||||
"""Dynamically adjust backlight brightness (0.0 to 1.0)."""
|
||||
self.brightness = max(0.0, min(1.0, float(brightness)))
|
||||
|
||||
def set_microwave_count(self, count):
|
||||
"""Set the number of connected microwaves."""
|
||||
self._microwave_count = count
|
||||
|
||||
def set_external_screen_count(self, count):
|
||||
"""Set the number of connected external screens."""
|
||||
self._external_screen_count = count
|
||||
|
||||
def set_cloud_alert(self, status: bool):
|
||||
"""Set cloud connectivity status."""
|
||||
self._cloud_alert = status
|
||||
|
||||
def set_data(self, microwave_count=None, external_screen_count=None, cloud_alert=None):
|
||||
"""Convenience method to update all state variables at once."""
|
||||
if microwave_count is not None:
|
||||
self.set_microwave_count(microwave_count)
|
||||
if external_screen_count is not None:
|
||||
self.set_external_screen_count(external_screen_count)
|
||||
if cloud_alert is not None:
|
||||
self.set_cloud_alert(cloud_alert)
|
||||
|
||||
# --- DISPLAY RENDERERS ---
|
||||
|
||||
def display_microwave_state(self):
|
||||
"""Display the number of connected microwaves."""
|
||||
text = f"Microwaves:\nConnected: {self._microwave_count}"
|
||||
self.rgb_lcd.setText(text)
|
||||
self.rgb_lcd.setRGB(255, 255, 255, brightness=self.brightness)
|
||||
|
||||
def display_external_screens(self):
|
||||
"""Display the number of connected external screens."""
|
||||
text = f"Screens:\nConnected: {self._external_screen_count}"
|
||||
self.rgb_lcd.setText(text)
|
||||
self.rgb_lcd.setRGB(255, 255, 255, brightness=self.brightness)
|
||||
|
||||
def display_cloud_connectivity(self):
|
||||
"""Display cloud connectivity status with brightness-scaled backlight color."""
|
||||
status_str = None
|
||||
|
||||
# Base RGB colors
|
||||
if self._cloud_alert is True:
|
||||
rgb = (255, 0, 0) # Red
|
||||
status_str = "Disconnected"
|
||||
elif self._cloud_alert is False:
|
||||
rgb = (0, 255, 0) # Green
|
||||
status_str = "Connected"
|
||||
else:
|
||||
rgb = (255, 165, 0) # Orange
|
||||
status_str = "N/A"
|
||||
|
||||
self.rgb_lcd.setRGB(rgb[0], rgb[1], rgb[2], brightness=self.brightness)
|
||||
self.rgb_lcd.setText(f"Cloud Status:\n{status_str}")
|
||||
|
||||
# --- BACKGROUND THREAD LOGIC ---
|
||||
|
||||
def _display_loop(self):
|
||||
"""Background loop that switches screens at regular intervals."""
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
self.screens[self.current_screen_idx]()
|
||||
self.current_screen_idx = (self.current_screen_idx + 1) % len(self.screens)
|
||||
except Exception as e:
|
||||
print(f"[RGBLCDManager] Error updating LCD: {e}")
|
||||
|
||||
sleep_ticks = int(self.interval * 10)
|
||||
for _ in range(sleep_ticks):
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
def start(self):
|
||||
"""Start the display background thread."""
|
||||
if not self._running:
|
||||
self._running = True
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(target=self._display_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
"""Stop the display loop and turn off the LCD backlight."""
|
||||
if self._running:
|
||||
self._stop_event.set()
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=2.0)
|
||||
self._running = False
|
||||
|
||||
try:
|
||||
self.rgb_lcd.setText("Bye !")
|
||||
self.rgb_lcd.setRGB(0, 0, 0)
|
||||
except Exception as e:
|
||||
print(f"[RGBLCDManager] Error clearing LCD: {e}")
|
||||
@@ -4,4 +4,7 @@ import sensors.ultrasonicRanger as ultrasonicRanger
|
||||
import sensors.temp_hum as temp_hum
|
||||
import sensors.button as button
|
||||
import sensors.gps as gps
|
||||
import sensors.camera as camera
|
||||
import sensors.camera as camera
|
||||
import sensors.buzzer as buzzer
|
||||
import sensors.rgb_lcd as rgb_lcd
|
||||
import sensors.rfid_reader as rfid_reader
|
||||
@@ -1,43 +1,64 @@
|
||||
import grovepi
|
||||
import time
|
||||
import threading
|
||||
from sensors.lock import grove_lock
|
||||
from sensors.lock import safe_grove_access
|
||||
from shared.logging import log
|
||||
|
||||
button = 2
|
||||
button_switch_state = 0
|
||||
grovepi.pinMode(button, "INPUT")
|
||||
|
||||
button_callback = None
|
||||
|
||||
def read_button_state():
|
||||
# Increase timeout slightly so the button thread can wait for long I2C sensor reads to finish
|
||||
if not grove_lock.acquire(timeout=0.2):
|
||||
return None
|
||||
try:
|
||||
return grovepi.digitalRead(button)
|
||||
except Exception as e:
|
||||
log(f"BTN Error: {e}")
|
||||
return None
|
||||
finally:
|
||||
grove_lock.release()
|
||||
# Attempt to acquire the lock with a 0.2s timeout
|
||||
with safe_grove_access(timeout=0.2) as acquired:
|
||||
if not acquired:
|
||||
return None
|
||||
|
||||
try:
|
||||
val = grovepi.digitalRead(button)
|
||||
# Force strict binary output (0 or 1)
|
||||
return 1 if val == 1 else 0
|
||||
except Exception as e:
|
||||
log(f"BTN Error: {e}")
|
||||
return None
|
||||
|
||||
def monitor_button():
|
||||
global button_switch_state
|
||||
last_button_state = button_switch_state
|
||||
last_stable_state = 0
|
||||
candidate_state = 0
|
||||
consecutive_count = 0
|
||||
|
||||
REQUIRED_CONSECUTIVE_READS = 3
|
||||
DEBOUNCE_COOLDOWN = 0.3
|
||||
last_trigger_time = 0
|
||||
|
||||
while True:
|
||||
current_state = read_button_state()
|
||||
|
||||
if current_state is not None:
|
||||
# Rising edge detection (0 -> 1 transition)
|
||||
if current_state == 1 and last_button_state == 0:
|
||||
if button_callback:
|
||||
button_callback()
|
||||
last_button_state = current_state
|
||||
time.sleep(0.02) # Fast 20ms poll when lock is clear
|
||||
if current_state == candidate_state:
|
||||
consecutive_count += 1
|
||||
else:
|
||||
candidate_state = current_state
|
||||
consecutive_count = 1
|
||||
|
||||
if consecutive_count >= REQUIRED_CONSECUTIVE_READS:
|
||||
now = time.time()
|
||||
|
||||
if candidate_state == 1 and last_stable_state == 0:
|
||||
if (now - last_trigger_time) > DEBOUNCE_COOLDOWN:
|
||||
last_trigger_time = now
|
||||
if button_callback:
|
||||
try:
|
||||
button_callback()
|
||||
except Exception as e:
|
||||
log(f"[Button] Callback exception: {e}")
|
||||
|
||||
last_stable_state = candidate_state
|
||||
|
||||
time.sleep(0.02) # 20ms poll interval
|
||||
else:
|
||||
# Lock was busy; retry quickly without updating last_button_state
|
||||
consecutive_count = 0
|
||||
time.sleep(0.01)
|
||||
|
||||
def start_button_monitoring_thread():
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import time
|
||||
import threading
|
||||
import grovepi
|
||||
from sensors.lock import safe_grove_access
|
||||
from shared import config
|
||||
|
||||
BUZZER_PIN = 8 # Must be a PWM pin on GrovePi (D3, D5, D6, D8 support analogWrite)
|
||||
|
||||
# Initialize pin mode safely
|
||||
try:
|
||||
with safe_grove_access(timeout=1.0) as acquired:
|
||||
if acquired:
|
||||
grovepi.pinMode(BUZZER_PIN, "OUTPUT")
|
||||
except Exception as e:
|
||||
print(f"[BUZZER] Init error: {e}")
|
||||
|
||||
def _stop_sound():
|
||||
"""Helper to ensure sound is silenced safely under lock."""
|
||||
with safe_grove_access(timeout=1.0) as acquired:
|
||||
if acquired:
|
||||
try:
|
||||
grovepi.analogWrite(BUZZER_PIN, 0)
|
||||
grovepi.digitalWrite(BUZZER_PIN, 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _siren_worker(beats_nb: int, delay: float):
|
||||
if not config.BUZZER_ACTIVATED:
|
||||
return
|
||||
|
||||
try:
|
||||
for _ in range(beats_nb):
|
||||
# 1. Turn sound ON (only acquire lock briefly for the I2C write)
|
||||
with safe_grove_access(timeout=1.0) as acquired:
|
||||
if acquired:
|
||||
grovepi.analogWrite(BUZZER_PIN, 128)
|
||||
time.sleep(delay) # Sleep WITHOUT holding the lock!
|
||||
|
||||
# 2. Turn sound OFF
|
||||
with safe_grove_access(timeout=1.0) as acquired:
|
||||
if acquired:
|
||||
grovepi.analogWrite(BUZZER_PIN, 0)
|
||||
time.sleep(delay) # Sleep WITHOUT holding the lock!
|
||||
except Exception as e:
|
||||
print(f"[BUZZER] Error during siren execution: {e}")
|
||||
finally:
|
||||
_stop_sound()
|
||||
|
||||
def buzzer_siren(beats_nb: int = 3, delay: float = 0.2, async_run: bool = True):
|
||||
if async_run:
|
||||
thread = threading.Thread(target=_siren_worker, args=(beats_nb, delay), daemon=True)
|
||||
thread.start()
|
||||
else:
|
||||
_siren_worker(beats_nb, delay)
|
||||
@@ -1,12 +1,25 @@
|
||||
import grovepi
|
||||
import math
|
||||
from sensors.lock import grove_lock
|
||||
from picamera2 import Picamera2, Preview
|
||||
import time
|
||||
|
||||
picam2 = Picamera2()
|
||||
|
||||
camera_config = picam2.create_still_configuration()
|
||||
# Get maximum full-sensor dimensions
|
||||
sensor_format = picam2.sensor_modes[0]
|
||||
max_width = sensor_format['size'][0]
|
||||
max_height = sensor_format['size'][1]
|
||||
|
||||
camera_config = picam2.create_still_configuration(
|
||||
raw={"size": (1640, 1232)},
|
||||
main={"size": (1640, 1232)}
|
||||
)
|
||||
picam2.set_controls({
|
||||
"AeEnable": True,
|
||||
"AwbEnable": True,
|
||||
"ExposureTime": 100000,
|
||||
"ExposureValue": 0.3,
|
||||
"Brightness": 0.30,
|
||||
"Contrast": 1.1
|
||||
})
|
||||
picam2.configure(camera_config)
|
||||
|
||||
picam2.start()
|
||||
|
||||
@@ -2,7 +2,7 @@ import serial
|
||||
import time
|
||||
import threading
|
||||
from shared.logging import log
|
||||
from sensors.lock import serial_lock
|
||||
from sensors.lock import safe_serial_access
|
||||
|
||||
def calculate_nmea_checksum(line: str) -> bool:
|
||||
"""Validates standard NMEA 0183 sentence checksum ($...*HH)."""
|
||||
@@ -34,25 +34,27 @@ class GROVEGPS:
|
||||
self.longitude = -1.0
|
||||
|
||||
def read(self):
|
||||
"""Reads the latest GGA sentence from serial, thread-safely."""
|
||||
with serial_lock:
|
||||
# 1. Flush accumulated stale data in the UART buffer
|
||||
if self.ser.in_waiting > 0:
|
||||
self.ser.reset_input_buffer()
|
||||
"""Reads the freshest GGA sentence from serial, thread-safely."""
|
||||
# Allow sufficient time for the serial reads (readline can block up to timeout=1s per line)
|
||||
with safe_serial_access(timeout=2.0) as acquired:
|
||||
if not acquired:
|
||||
log("GPS: Serial lock acquisition timed out")
|
||||
return False
|
||||
|
||||
# 2. Try reading up to 15 lines to catch the freshest GGA sentence
|
||||
for _ in range(5):
|
||||
# 1. Flush accumulated stale buffer data
|
||||
self.ser.reset_input_buffer()
|
||||
|
||||
# 2. Read through the fresh incoming stream to find a complete GGA line
|
||||
for _ in range(15):
|
||||
raw_bytes = self.ser.readline()
|
||||
try:
|
||||
line = raw_bytes.decode('utf-8', errors='ignore').strip()
|
||||
# log(f"GPS: Read line: {line}")
|
||||
log(f"GPS: Read line: {line}")
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Supports both $GPGGA and modern $GNGGA sentences
|
||||
if (line.startswith('$GPGGA') or line.startswith('$GNGGA')) and calculate_nmea_checksum(line):
|
||||
if self.parse_gga(line):
|
||||
return True
|
||||
return self.parse_gga(line)
|
||||
return False
|
||||
|
||||
def parse_gga(self, line):
|
||||
@@ -118,5 +120,4 @@ def get_gps_data():
|
||||
else:
|
||||
log(f"GPS: No valid fix or data available. Satellites: {gps.satellites}, Quality: {gps.quality}")
|
||||
|
||||
# Return None so main.py doesn't process or log empty GPS data
|
||||
return None
|
||||
@@ -1,5 +1,31 @@
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
|
||||
# Dedicated lock for I2C bus access (used by GrovePi sensors)
|
||||
grove_lock = threading.Lock()
|
||||
# Dedicated lock for UART/Serial port access
|
||||
serial_lock = threading.Lock()
|
||||
serial_lock = threading.Lock()
|
||||
|
||||
@contextmanager
|
||||
def safe_grove_access(timeout=1.0):
|
||||
"""
|
||||
Safely acquire the Grove I2C lock with a timeout.
|
||||
Yields True if lock acquired, False otherwise.
|
||||
Guarantees lock release only when successfully acquired.
|
||||
"""
|
||||
acquired = grove_lock.acquire(timeout=timeout)
|
||||
try:
|
||||
yield acquired
|
||||
finally:
|
||||
if acquired:
|
||||
grove_lock.release()
|
||||
|
||||
@contextmanager
|
||||
def safe_serial_access(timeout=1.0):
|
||||
"""Safely acquire the UART/Serial lock with a timeout."""
|
||||
acquired = serial_lock.acquire(timeout=timeout)
|
||||
try:
|
||||
yield acquired
|
||||
finally:
|
||||
if acquired:
|
||||
serial_lock.release()
|
||||
@@ -0,0 +1,101 @@
|
||||
import time
|
||||
import pigpio
|
||||
from typing import Optional
|
||||
from sensors.lock import safe_serial_access
|
||||
|
||||
|
||||
class RFIDReader:
|
||||
"""
|
||||
Grove 125kHz RFID Reader using bit-banged software serial on GPIO 17 (Pin 11).
|
||||
Parses 14-byte frame: [0x02 STX] + [10 ASCII ID] + [2 ASCII Checksum] + [0x03 ETX]
|
||||
"""
|
||||
|
||||
def __init__(self, rx_pin: int = 17, baudrate: int = 9600):
|
||||
self.rx_pin = rx_pin
|
||||
self.baudrate = baudrate
|
||||
self.buffer = bytearray()
|
||||
self.pi = pigpio.pi()
|
||||
|
||||
if not self.pi.connected:
|
||||
raise RuntimeError("pigpio daemon is not running. Run 'sudo systemctl start pigpiod'.")
|
||||
|
||||
with safe_serial_access(timeout=1.0) as acquired:
|
||||
if not acquired:
|
||||
raise RuntimeError("RFID: Could not acquire serial lock during initialization")
|
||||
|
||||
self.pi.set_mode(self.rx_pin, pigpio.INPUT)
|
||||
|
||||
# Clean up lingering serial sessions on this pin
|
||||
try:
|
||||
self.pi.bb_serial_read_close(self.rx_pin)
|
||||
except pigpio.error:
|
||||
pass
|
||||
|
||||
self.pi.bb_serial_read_open(self.rx_pin, self.baudrate, 8)
|
||||
|
||||
def read_tag(self) -> Optional[str]:
|
||||
"""
|
||||
Reads and accumulates bytes, returning the 10-digit Tag ID.
|
||||
"""
|
||||
with safe_serial_access(timeout=0.5) as acquired:
|
||||
if not acquired:
|
||||
return None
|
||||
|
||||
if not self.pi or not self.pi.connected:
|
||||
return None
|
||||
|
||||
count, data = self.pi.bb_serial_read(self.rx_pin)
|
||||
if count > 0:
|
||||
self.buffer.extend(data)
|
||||
|
||||
# Look for Start-of-Text (0x02)
|
||||
stx_idx = self.buffer.find(b"\x02")
|
||||
|
||||
if stx_idx != -1:
|
||||
# Discard noise prior to 0x02
|
||||
if stx_idx > 0:
|
||||
self.buffer = self.buffer[stx_idx:]
|
||||
|
||||
# Wait for full 14-byte payload
|
||||
if len(self.buffer) >= 14:
|
||||
raw_frame = self.buffer[:14]
|
||||
self.buffer = self.buffer[14:] # Flush parsed frame
|
||||
|
||||
# Verify End-of-Text (0x03)
|
||||
if raw_frame[-1] == 0x03:
|
||||
try:
|
||||
# Extract 10-character ID (indices 1 through 10)
|
||||
return raw_frame[1:11].decode("ascii")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
else:
|
||||
# Flush buffer if filled with noise without STX marker
|
||||
if len(self.buffer) > 64:
|
||||
self.buffer.clear()
|
||||
|
||||
return None
|
||||
|
||||
def close(self):
|
||||
with safe_serial_access(timeout=1.0) as acquired:
|
||||
if self.pi and self.pi.connected:
|
||||
try:
|
||||
self.pi.bb_serial_read_close(self.rx_pin)
|
||||
except pigpio.error:
|
||||
pass
|
||||
self.pi.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
reader = RFIDReader(rx_pin=17)
|
||||
print("RFID Reader active on GPIO 17 (Pin 11). Swipe a tag...")
|
||||
|
||||
try:
|
||||
while True:
|
||||
tag = reader.read_tag()
|
||||
if tag:
|
||||
print(f"Scanned Tag ID: {tag}")
|
||||
time.sleep(0.05)
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopping reader.")
|
||||
finally:
|
||||
reader.close()
|
||||
@@ -0,0 +1,104 @@
|
||||
import time
|
||||
import sys
|
||||
from sensors.lock import safe_grove_access
|
||||
|
||||
if sys.platform == 'uwp':
|
||||
import winrt_smbus as smbus
|
||||
bus = smbus.SMBus(1)
|
||||
else:
|
||||
import smbus
|
||||
import RPi.GPIO as GPIO
|
||||
rev = GPIO.RPI_REVISION
|
||||
if rev == 2 or rev == 3:
|
||||
bus = smbus.SMBus(1)
|
||||
else:
|
||||
bus = smbus.SMBus(0)
|
||||
|
||||
# Device I2C addresses
|
||||
DISPLAY_RGB_ADDR = 0x62
|
||||
DISPLAY_TEXT_ADDR = 0x3e
|
||||
|
||||
def setRGB(r, g, b, brightness=1.0):
|
||||
"""Set backlight to (R,G,B) with optional brightness level (0.0 to 1.0)."""
|
||||
brightness = max(0.0, min(1.0, float(brightness)))
|
||||
|
||||
r_scaled = int(max(0, min(255, r * brightness)))
|
||||
g_scaled = int(max(0, min(255, g * brightness)))
|
||||
b_scaled = int(max(0, min(255, b * brightness)))
|
||||
|
||||
with safe_grove_access(timeout=1.0) as acquired:
|
||||
if not acquired:
|
||||
return
|
||||
try:
|
||||
bus.write_byte_data(DISPLAY_RGB_ADDR, 0, 0)
|
||||
bus.write_byte_data(DISPLAY_RGB_ADDR, 1, 0)
|
||||
bus.write_byte_data(DISPLAY_RGB_ADDR, 0x08, 0xaa)
|
||||
bus.write_byte_data(DISPLAY_RGB_ADDR, 4, r_scaled)
|
||||
bus.write_byte_data(DISPLAY_RGB_ADDR, 3, g_scaled)
|
||||
bus.write_byte_data(DISPLAY_RGB_ADDR, 2, b_scaled)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def textCommand(cmd):
|
||||
"""Send command to display (internal use)."""
|
||||
bus.write_byte_data(DISPLAY_TEXT_ADDR, 0x80, cmd)
|
||||
|
||||
def setText(text):
|
||||
"""Set display text (\n for second line or auto wrap)."""
|
||||
with safe_grove_access(timeout=1.5) as acquired:
|
||||
if not acquired:
|
||||
return
|
||||
try:
|
||||
textCommand(0x01) # Clear display
|
||||
time.sleep(0.05)
|
||||
textCommand(0x08 | 0x04) # Display on, no cursor
|
||||
textCommand(0x28) # 2 lines
|
||||
time.sleep(0.05)
|
||||
|
||||
count = 0
|
||||
row = 0
|
||||
for c in text:
|
||||
if c == '\n' or count == 16:
|
||||
count = 0
|
||||
row += 1
|
||||
if row == 2:
|
||||
break
|
||||
textCommand(0xc0)
|
||||
if c == '\n':
|
||||
continue
|
||||
count += 1
|
||||
bus.write_byte_data(DISPLAY_TEXT_ADDR, 0x40, ord(c))
|
||||
time.sleep(0.001)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def setText_norefresh(text):
|
||||
"""Update display text without full screen erase."""
|
||||
with safe_grove_access(timeout=1.5) as acquired:
|
||||
if not acquired:
|
||||
return
|
||||
try:
|
||||
textCommand(0x02) # Return home
|
||||
time.sleep(0.05)
|
||||
textCommand(0x08 | 0x04) # Display on, no cursor
|
||||
textCommand(0x28) # 2 lines
|
||||
time.sleep(0.05)
|
||||
|
||||
count = 0
|
||||
row = 0
|
||||
while len(text) < 32: # Clear rest of screen space
|
||||
text += ' '
|
||||
for c in text:
|
||||
if c == '\n' or count == 16:
|
||||
count = 0
|
||||
row += 1
|
||||
if row == 2:
|
||||
break
|
||||
textCommand(0xc0)
|
||||
if c == '\n':
|
||||
continue
|
||||
count += 1
|
||||
bus.write_byte_data(DISPLAY_TEXT_ADDR, 0x40, ord(c))
|
||||
time.sleep(0.001)
|
||||
except OSError:
|
||||
pass
|
||||
@@ -15,17 +15,28 @@ white = 1 # The White colored sensor.
|
||||
|
||||
def get_temperature_and_humidity():
|
||||
with grove_lock:
|
||||
[temp,humidity] = grovepi.dht(sensor,blue)
|
||||
if math.isnan(temp) == False and math.isnan(humidity) == False:
|
||||
return temp, humidity
|
||||
else:
|
||||
print("Error reading from DHT sensor")
|
||||
return None, None
|
||||
try:
|
||||
[temp, humidity] = grovepi.dht(sensor, blue)
|
||||
if not math.isnan(temp) and not math.isnan(humidity) and temp > -40 and humidity >= 0:
|
||||
return temp, humidity
|
||||
except Exception:
|
||||
pass
|
||||
return None, None
|
||||
|
||||
def get_temperature_and_humidity_with_retry(max_retries=3):
|
||||
for _ in range(max_retries): # Try up to max_retries times
|
||||
|
||||
def get_temperature_and_humidity_with_retry(max_retries=4, retry_delay=2.5):
|
||||
"""
|
||||
Reads temperature and humidity with retries.
|
||||
Uses a 1.5s delay between attempts to respect DHT sampling limits.
|
||||
"""
|
||||
for attempt in range(max_retries):
|
||||
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
|
||||
|
||||
# Wait before retrying (DHT sensors require >= 1s between reads)
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(retry_delay)
|
||||
|
||||
print("Warning: All DHT sensor read attempts failed.")
|
||||
return None, None
|
||||
@@ -1,33 +1,24 @@
|
||||
import grovepi
|
||||
from sensors.lock import grove_lock
|
||||
from sensors.lock import safe_grove_access
|
||||
from shared import config
|
||||
|
||||
# Connect the Grove Ultrasonic Ranger to digital port D4
|
||||
# SIG,NC,VCC,GND
|
||||
ULTRASONIC_RANGER_PORT = 4
|
||||
|
||||
def read_ultrasonic_ranger(ultrasonic_ranger=ULTRASONIC_RANGER_PORT):
|
||||
if not grove_lock.acquire(timeout=1.0):
|
||||
print("Ultrasonic: Lock acquisition timed out")
|
||||
return None
|
||||
|
||||
try:
|
||||
return grovepi.ultrasonicRead(ultrasonic_ranger)
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return None
|
||||
finally:
|
||||
grove_lock.release()
|
||||
|
||||
with safe_grove_access(timeout=1.5) as acquired:
|
||||
if not acquired:
|
||||
return None
|
||||
|
||||
try:
|
||||
return grovepi.ultrasonicRead(ultrasonic_ranger)
|
||||
except Exception as e:
|
||||
print(f"Ultrasonic read error: {e}")
|
||||
return None
|
||||
|
||||
def get_dish_height():
|
||||
"""Returns the height of the dish in centimeters."""
|
||||
distance = read_ultrasonic_ranger()
|
||||
if distance is not None:
|
||||
# Assuming the ultrasonic sensor is mounted at a fixed height above the dish
|
||||
# and pointing downwards, we can calculate the height of the dish.
|
||||
# For example, if the sensor is 30 cm above the dish when it's empty:
|
||||
# cm
|
||||
dish_height = config.COOKING_COMPARTMENT_HEIGHT - distance
|
||||
return max(dish_height, 0) # Ensure height is not negative
|
||||
else:
|
||||
return None
|
||||
return max(dish_height, 0)
|
||||
return None
|
||||
@@ -9,6 +9,7 @@ User=pi
|
||||
Group=pi
|
||||
WorkingDirectory=/home/pi/SmartWave
|
||||
EnvironmentFile=-/etc/default/smartwave
|
||||
Environment="PYTHONUNBUFFERED=1"
|
||||
ExecStart=/bin/sh /home/pi/SmartWave/orchestrateur/launch.sh /home/pi/SmartWave/orchestrateur/main.py
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import asyncio
|
||||
from fastapi import FastAPI, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
import uvicorn
|
||||
|
||||
HTML_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SmartWave Technician Portal</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: "Segoe UI", Roboto, monospace; background: #121212; color: #e0e0e0; height: 100vh; overflow: hidden; }
|
||||
.container { display: flex; height: 100vh; }
|
||||
|
||||
.logs-section { flex: 1; display: flex; flex-direction: column; border-right: 1px solid #333; padding: 15px; }
|
||||
.logs-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.logs-header h2 { font-size: 1.1rem; color: #4fc3f7; }
|
||||
.logs-header button { background: #333; color: #fff; border: 1px solid #555; padding: 4px 10px; border-radius: 4px; cursor: pointer; }
|
||||
pre#log-output { flex: 1; background: #000; color: #00ff66; padding: 12px; border-radius: 6px; overflow-y: auto; font-size: 0.85rem; line-height: 1.4; white-space: pre-wrap; word-break: break-all; }
|
||||
|
||||
.control-section { width: 500px; padding: 20px; background: #1a1a1a; display: flex; flex-direction: column; gap: 15px; }
|
||||
.control-section h2 { font-size: 1.1rem; color: #ffb74d; margin-bottom: 5px; }
|
||||
.form-group { display: flex; flex-direction: column; gap: 6px; }
|
||||
label { font-size: 0.85rem; color: #aaa; }
|
||||
input[type="text"] { background: #2a2a2a; border: 1px solid #444; color: #fff; padding: 10px; border-radius: 4px; font-size: 0.9rem; }
|
||||
input[type="text"]:focus { outline: none; border-color: #ffb74d; }
|
||||
button.btn-submit { background: #ffb74d; color: #121212; border: none; padding: 12px; font-weight: bold; border-radius: 4px; cursor: pointer; transition: background 0.2s; }
|
||||
button.btn-submit:hover { background: #ffa726; }
|
||||
#status-msg { font-size: 0.85rem; padding: 8px; border-radius: 4px; display: none; }
|
||||
.success { background: #1b5e20; color: #a5d6a7; }
|
||||
.error { background: #b71c1c; color: #ef9a9a; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="logs-section">
|
||||
<div class="logs-header">
|
||||
<h2>SmartWave Orchestrator System Logs</h2>
|
||||
<button onclick="fetchLogs()">Refresh</button>
|
||||
</div>
|
||||
<pre id="log-output">Loading logs...</pre>
|
||||
</div>
|
||||
|
||||
<div class="control-section">
|
||||
<h2>Telemetry Control</h2>
|
||||
<div class="form-group">
|
||||
<label for="endpoint">Telemetry Endpoint URL:</label>
|
||||
<input type="text" id="endpoint" value="https://smartwave.matthiasg.dev/telemetry">
|
||||
</div>
|
||||
<button class="btn-submit" onclick="triggerTelemetry()">Send Telemetry</button>
|
||||
<div id="status-msg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function fetchLogs() {
|
||||
try {
|
||||
const res = await fetch('/api/logs');
|
||||
const logs = await res.json();
|
||||
const logContainer = document.getElementById('log-output');
|
||||
|
||||
logContainer.textContent = logs.map(entry => {
|
||||
const ts = entry.timestamp ? entry.timestamp.replace('T', ' ').split('.')[0] : 'RAW';
|
||||
const pid = entry.pid ? `[PID:${entry.pid}]` : '';
|
||||
return `${ts} ${pid} ${entry.message}`;
|
||||
}).join('\\n');
|
||||
|
||||
logContainer.scrollTop = logContainer.scrollHeight;
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch logs", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerTelemetry() {
|
||||
const endpoint = document.getElementById('endpoint').value;
|
||||
const statusDiv = document.getElementById('status-msg');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.className = '';
|
||||
statusDiv.textContent = 'Sending telemetry...';
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('endpoint', endpoint);
|
||||
|
||||
const res = await fetch('/api/telemetry', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok && data.status === 'ok') {
|
||||
statusDiv.className = 'success';
|
||||
statusDiv.textContent = 'Telemetry sent successfully!';
|
||||
} else {
|
||||
throw new Error(data.detail || 'Failed');
|
||||
}
|
||||
} catch (err) {
|
||||
statusDiv.className = 'error';
|
||||
statusDiv.textContent = 'Error sending telemetry: ' + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
fetchLogs();
|
||||
setInterval(fetchLogs, 3000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
def create_app(get_logs_cb, telemetry_cb, is_tech_mode_cb) -> FastAPI:
|
||||
"""Factory creating FastAPI app with injected handler callbacks."""
|
||||
app = FastAPI(title="Technician Portal")
|
||||
|
||||
# Attach callbacks to app.state
|
||||
app.state.get_systemd_logs = get_logs_cb
|
||||
app.state.handle_telemetry_request = telemetry_cb
|
||||
app.state.is_tech_mode = is_tech_mode_cb
|
||||
|
||||
# Middleware to block traffic when TECHNICIAN_MODE is False
|
||||
@app.middleware("http")
|
||||
async def enforce_technician_mode(request: Request, call_next):
|
||||
if not request.app.state.is_tech_mode():
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={"detail": "Technician mode is disabled."}
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def serve_dashboard():
|
||||
return HTML_TEMPLATE
|
||||
|
||||
@app.get("/api/logs")
|
||||
async def api_get_logs(request: Request):
|
||||
try:
|
||||
logs = await request.app.state.get_systemd_logs(lines=200, service_name="smartwave")
|
||||
return JSONResponse(content=logs)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post("/api/telemetry")
|
||||
async def api_trigger_telemetry(request: Request, endpoint: str = Form(...)):
|
||||
try:
|
||||
await request.app.state.handle_telemetry_request(endpoint=endpoint, do_timeout=True)
|
||||
return {"status": "ok", "message": f"Telemetry request sent to {endpoint}"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Telemetry trigger failed: {str(e)}")
|
||||
|
||||
return app
|
||||
|
||||
async def start_technician_web_server(get_logs_cb, telemetry_cb, is_tech_mode_cb, host="192.168.50.1", port=8080):
|
||||
"""Asynchronous server launcher meant to run within main.py's event loop."""
|
||||
app = create_app(get_logs_cb, telemetry_cb, is_tech_mode_cb)
|
||||
config = uvicorn.Config(app, host=host, port=port, log_level="info")
|
||||
server = uvicorn.Server(config)
|
||||
await server.serve()
|
||||
+4
-2
@@ -6,13 +6,15 @@ import shared.config as config
|
||||
import shared.payloads as payloads
|
||||
import shared.cookingState as cookingState
|
||||
import shared.safeQueue as safeQueue
|
||||
import shared.alerts as alerts
|
||||
import shared.microwave_state as microwave_state
|
||||
try:
|
||||
import shared.lora_device as lora_device
|
||||
except ImportError:
|
||||
except (ImportError, SyntaxError):
|
||||
pass # No need
|
||||
try:
|
||||
import shared.uart_comm as uart_comm
|
||||
except ImportError:
|
||||
except (ImportError, SyntaxError):
|
||||
pass # No need as we are on the RPI
|
||||
import shared.sensors
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import time
|
||||
try:
|
||||
import asyncio
|
||||
import inspect
|
||||
ASYNCIO = True
|
||||
except ImportError:
|
||||
ASYNCIO = False
|
||||
|
||||
class AlertManager:
|
||||
def __init__(self):
|
||||
self.alerts = []
|
||||
self.on_alert_callback = None
|
||||
|
||||
def set_on_alert_callback(self, callback):
|
||||
self.on_alert_callback = callback
|
||||
|
||||
def call_on_alert_callback(self, alert):
|
||||
if self.on_alert_callback:
|
||||
if ASYNCIO and inspect.iscoroutinefunction(self.on_alert_callback):
|
||||
# Schedule coroutine on the running loop without blocking
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.create_task(self.on_alert_callback(alert))
|
||||
except RuntimeError:
|
||||
# Fallback if called outside an active event loop
|
||||
asyncio.run(self.on_alert_callback(alert))
|
||||
else:
|
||||
self.on_alert_callback(alert)
|
||||
|
||||
def add_alert(self, alert):
|
||||
self.alerts.append(alert)
|
||||
self.call_on_alert_callback(alert)
|
||||
|
||||
def get_alerts(self):
|
||||
return self.alerts
|
||||
|
||||
def clear_alerts(self):
|
||||
self.alerts.clear()
|
||||
|
||||
class Alert:
|
||||
def __init__(self, alertType: "AlertType", message: str, redistributed: bool = False, small_message: str = None):
|
||||
self.alertType = alertType
|
||||
self.message = message
|
||||
self.small_message = small_message
|
||||
self.timestamp = time.time()
|
||||
self.redistributed = redistributed
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"type": self.alertType,
|
||||
"message": self.message,
|
||||
"timestamp": self.timestamp
|
||||
}
|
||||
|
||||
def to_json(self):
|
||||
import json
|
||||
return json.dumps(self.to_dict())
|
||||
|
||||
def from_json(json_string: str) -> "Alert":
|
||||
import json
|
||||
data = json.loads(json_string)
|
||||
alert = Alert(data["type"], data["message"])
|
||||
alert.timestamp = data["timestamp"]
|
||||
return alert
|
||||
|
||||
class AlertType:
|
||||
COOKING_SAFETY = "COOKING_SAFETY"
|
||||
TEMPERATURE_ALERT = "TEMPERATURE_ALERT"
|
||||
+18
-1
@@ -1,4 +1,14 @@
|
||||
# Debugging and others
|
||||
DEBUG=True
|
||||
DEBUG_MESSAGES=True
|
||||
DEBUG_DANGEROUS_AREA=False
|
||||
DEBUG_TEMPERATURE_ALERT=False
|
||||
DEBUG_SHORTER_COOKING_PLAN=True
|
||||
DEBUG_DISHANALYZER=True
|
||||
|
||||
# Technitian Web Server
|
||||
TECHNICIAN_WEB_SERVER_HOST = "192.168.50.1"
|
||||
TECHNICIAN_WEB_SERVER_PORT = 8080
|
||||
|
||||
# LoRa
|
||||
LORA_HEARTBEAT_INTERVAL = 30
|
||||
@@ -11,8 +21,15 @@ MQTT_TOPIC_COOKING = b"smartwave/cooking"
|
||||
MQTT_KEEPALIVE = 30
|
||||
USE_TLS = True
|
||||
MQTT_QOS = 1
|
||||
CLOUD_MQTT_QOS = 2
|
||||
# Long because messages are stored into the broker and will be sent when the orchestrator is back online.
|
||||
MQTT_HELLO_INTERVAL = 30
|
||||
|
||||
# Microwave Model
|
||||
COOKING_COMPARTMENT_HEIGHT = 30 # cm
|
||||
COOKING_COMPARTMENT_HEIGHT = 20.5 # cm
|
||||
BUZZER_ACTIVATED = True
|
||||
MAX_ALLOWED_TEMPERATURE_TECHNICAL_COMPARTMENT = 70
|
||||
MAX_ALLOWED_TEMPERATURE_COOKING_COMPARTMENT = 200
|
||||
|
||||
# TELEMETRY
|
||||
TELEMETRY_SEND_INTERVAL = 3
|
||||
+72
-30
@@ -3,6 +3,7 @@ import time
|
||||
|
||||
class CookingState:
|
||||
TEMPERATURE_TOLERANCE = 1.0
|
||||
MIN_SIGNIFICANT_HEATING_RATE = 0.05 # °C/s threshold to consider valid heating
|
||||
|
||||
def __init__(self, cook_time: int, power_level: int, target_temp: float, temperature_provider=None, on_state_change=None, on_refresh=None):
|
||||
self.cook_time = cook_time
|
||||
@@ -27,6 +28,9 @@ class CookingState:
|
||||
self._last_refresh_signature = None
|
||||
self._stirred = False
|
||||
|
||||
# Moving average filter for heating rate (°C / sec)
|
||||
self._smoothed_heating_rate = 0.0
|
||||
|
||||
def set_temperature_provider(self, temperature_provider):
|
||||
self.temperature_provider = temperature_provider
|
||||
|
||||
@@ -35,7 +39,7 @@ class CookingState:
|
||||
|
||||
def set_refresh_callback(self, callback):
|
||||
self.on_refresh = callback
|
||||
|
||||
|
||||
def set_pause_callback(self, callback):
|
||||
self.on_pause = callback
|
||||
|
||||
@@ -45,9 +49,9 @@ class CookingState:
|
||||
|
||||
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
|
||||
@@ -56,16 +60,15 @@ class CookingState:
|
||||
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)
|
||||
if self.on_pause:
|
||||
self.on_pause(self)
|
||||
|
||||
def set_state(self, state):
|
||||
if self.state == state:
|
||||
@@ -90,20 +93,56 @@ class CookingState:
|
||||
|
||||
def get_remaining_time_estimation(self) -> float:
|
||||
elapsed_time = self.get_elapsed_time()
|
||||
|
||||
# 1. Base timer remaining based on standard cook time
|
||||
timer_remaining = max(0.0, float(self.cook_time) - elapsed_time)
|
||||
|
||||
if self.current_dish_temp is None:
|
||||
# If temperature is unavailable or already met target, rely on standard timer
|
||||
if self.current_dish_temp is None or self.current_dish_temp >= (self.target_temp - self.TEMPERATURE_TOLERANCE):
|
||||
return timer_remaining
|
||||
|
||||
if self.current_dish_temp >= self.target_temp:
|
||||
return timer_remaining
|
||||
# 2. Prevent hitting 00:00 before stirring trigger:
|
||||
# If we passed half cook_time and temp is far from target, extend expected base time to 1.25x cook_time
|
||||
temp_progress = max(0.0, self.current_dish_temp) / max(1.0, self.target_temp)
|
||||
if elapsed_time > (self.cook_time * 0.5) and temp_progress < 0.8:
|
||||
adjusted_cook_time = self.cook_time * 1.25
|
||||
timer_remaining = max(0.0, adjusted_cook_time - elapsed_time)
|
||||
|
||||
# 3. Estimate using heating rate
|
||||
heating_rate = self._estimate_heating_rate()
|
||||
if heating_rate <= 0:
|
||||
return timer_remaining
|
||||
temp_needed = self.target_temp - self.current_dish_temp
|
||||
|
||||
target_remaining = (self.target_temp - self.current_dish_temp) / heating_rate
|
||||
return max(timer_remaining, max(0.0, target_remaining))
|
||||
if heating_rate > 0.01:
|
||||
rate_based_remaining = temp_needed / heating_rate
|
||||
else:
|
||||
# If flat/slow, project remaining time based on remaining missing temperature fraction
|
||||
temp_ratio = max(0.1, temp_needed / self.target_temp)
|
||||
rate_based_remaining = max(timer_remaining, self.cook_time * temp_ratio * 1.25)
|
||||
|
||||
# 4. Strict dynamic cap: Never exceed maximum possible execution window (2.0x cook_time total)
|
||||
max_possible_remaining = max(0.0, (self.cook_time * 2.0) - elapsed_time)
|
||||
bounded_remaining = min(rate_based_remaining, max_possible_remaining)
|
||||
|
||||
# Return the larger of the adjusted timer or the bounded prediction
|
||||
return max(timer_remaining, bounded_remaining)
|
||||
|
||||
def _estimate_heating_rate(self) -> float:
|
||||
"""Calculates heating rate in °C/sec over time interval."""
|
||||
if self._last_temperature_sample is None or self.current_dish_temp is None:
|
||||
return 0.0
|
||||
|
||||
last_time, last_temp = self._last_temperature_sample
|
||||
now = time.time()
|
||||
delta_time = now - last_time
|
||||
|
||||
if delta_time < 0.8: # Skip micro-ticks
|
||||
return 0.0
|
||||
|
||||
delta_temp = self.current_dish_temp - last_temp
|
||||
if delta_temp <= 0:
|
||||
return 0.0
|
||||
|
||||
return delta_temp / delta_time
|
||||
|
||||
def _read_temperatures(self):
|
||||
if self.temperature_provider is None:
|
||||
@@ -118,27 +157,29 @@ class CookingState:
|
||||
|
||||
raise ValueError("temperature_provider must return a pair: (dish_temp, ambient_temp)")
|
||||
|
||||
def _estimate_heating_rate(self):
|
||||
def _update_heating_rate(self):
|
||||
"""Calculates instantaneous rate and updates the Exponential Moving Average."""
|
||||
now = time.time()
|
||||
if self._last_temperature_sample is None:
|
||||
return 0.0
|
||||
self._last_temperature_sample = (now, self.current_dish_temp)
|
||||
return
|
||||
|
||||
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
|
||||
if delta_time <= 0.5 or self.current_dish_temp is None:
|
||||
return
|
||||
|
||||
instant_rate = (self.current_dish_temp - last_temp) / delta_time
|
||||
self._last_temperature_sample = (now, self.current_dish_temp)
|
||||
|
||||
# Exponential Moving Average (EMA) - alpha smooths out sensor noise
|
||||
alpha = 0.2
|
||||
self._smoothed_heating_rate = (alpha * instant_rate) + ((1.0 - alpha) * self._smoothed_heating_rate)
|
||||
|
||||
def _notify_state_change(self):
|
||||
if self.on_state_change is None:
|
||||
return
|
||||
|
||||
self.on_state_change(self)
|
||||
|
||||
def _notify_refresh(self, force=False):
|
||||
@@ -177,21 +218,22 @@ class CookingState:
|
||||
|
||||
now = time.time()
|
||||
elapsed_time = self.get_elapsed_time()
|
||||
self.estimated_remaining_time = self.get_remaining_time_estimation()
|
||||
|
||||
|
||||
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._update_heating_rate()
|
||||
|
||||
if elapsed_time < (self.cook_time / 2.0) and self.current_dish_temp >= self.target_temp and (self._paused_duration is None or self._paused_duration < 5):
|
||||
self.state = CookingStates.STIRRING_REQUIRED
|
||||
self.pause()
|
||||
elif elapsed_time >= self.cook_time and self.current_dish_temp >= (self.target_temp - self.TEMPERATURE_TOLERANCE):
|
||||
self.state = CookingStates.DONE
|
||||
elif elapsed_time >= self.cook_time * 1.25 and (self._paused_duration == None or self._paused_duration < 5): # If the dish is not heating up
|
||||
elif elapsed_time >= self.cook_time * 1.25 and (self._paused_duration is None or self._paused_duration < 5):
|
||||
self.state = CookingStates.STIRRING_REQUIRED
|
||||
self.pause()
|
||||
elif self._pause_started_at != 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
|
||||
elif self._pause_started_at is not None and (self._pause_started_at + self._paused_duration) < (now - (self.cook_time * 0.75)):
|
||||
self.state = CookingStates.DONE
|
||||
|
||||
self._last_temperature_sample = (now, self.current_dish_temp)
|
||||
self.estimated_remaining_time = self.get_remaining_time_estimation()
|
||||
|
||||
if self.state != previous_state:
|
||||
self._notify_state_change()
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
from shared.config import DEBUG
|
||||
from shared.config import DEBUG_MESSAGES
|
||||
|
||||
def log(message):
|
||||
"""Log a message to the console if DEBUG is enabled."""
|
||||
if DEBUG:
|
||||
if DEBUG_MESSAGES:
|
||||
print(f"\n{message}")
|
||||
+144
-106
@@ -20,11 +20,19 @@ else:
|
||||
class BaseLoraDevice:
|
||||
"""Base class providing automatic ACK generation, retries, and duplicate filtering."""
|
||||
|
||||
MAX_PAYLOAD_SIZE = 222 # Maximum safe LoRa payload size in bytes
|
||||
|
||||
def __init__(self):
|
||||
self.processed_msg_ids = set()
|
||||
self.received_acks = set()
|
||||
self.pending_rx_queue = []
|
||||
self.default_group = 2
|
||||
|
||||
# NEW: Global TX lock to prevent concurrent overlapping transmissions
|
||||
if IS_MICROPYTHON:
|
||||
self.tx_lock = _thread.allocate_lock()
|
||||
else:
|
||||
self.tx_lock = threading.Lock()
|
||||
|
||||
def _generate_msg_id(self):
|
||||
return random.getrandbits(16)
|
||||
@@ -33,10 +41,9 @@ class BaseLoraDevice:
|
||||
"""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(100)
|
||||
time.sleep_ms(50)
|
||||
else:
|
||||
# Give LA66 chip time to finish logging RSSI and reset RF frontend
|
||||
time.sleep(0.25)
|
||||
time.sleep(0.05) # Reduced delay to fit ESP32 RX window
|
||||
|
||||
ack_payload = {"_type": "_ack", "_ack_id": ack_id}
|
||||
self.send(ack_payload)
|
||||
@@ -74,53 +81,51 @@ class BaseLoraDevice:
|
||||
|
||||
return packet
|
||||
|
||||
def send_reliable(self, payload, max_retries=4, ack_timeout=3.0):
|
||||
def send_reliable(self, payload, max_retries=5, ack_timeout=3.0):
|
||||
"""Sends a payload and listens in a single continuous RX window for the ACK."""
|
||||
lock = getattr(self, 'lock', None)
|
||||
|
||||
if isinstance(payload, dict):
|
||||
payload = dict(payload)
|
||||
else:
|
||||
payload = {"data": payload}
|
||||
with self.tx_lock:
|
||||
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
|
||||
msg_id = self._generate_msg_id()
|
||||
payload["_msg_id"] = msg_id
|
||||
|
||||
log(f"\n[ReliableLoRa] === Starting send_reliable for msg_id {msg_id} ===")
|
||||
log(f"\n[ReliableLoRa] === Starting send_reliable for msg_id {msg_id} ===")
|
||||
|
||||
for attempt in range(max_retries):
|
||||
log(f"[ReliableLoRa] Attempt {attempt + 1}/{max_retries} transmitting msg_id {msg_id}")
|
||||
self.send(payload)
|
||||
for attempt in range(max_retries):
|
||||
log(f"[ReliableLoRa] Attempt {attempt + 1}/{max_retries} transmitting msg_id {msg_id}")
|
||||
self.send(payload)
|
||||
|
||||
# 1. Open a single continuous RX window for the full timeout duration
|
||||
# (Defaulted to 3.0s to account for LA66 UART + transmission time)
|
||||
timeout_ms = int(ack_timeout * 1000)
|
||||
packet = self.receive_packet(timeout_ms=timeout_ms)
|
||||
timeout_ms = int(ack_timeout * 1000)
|
||||
packet = self.receive_packet(timeout_ms=timeout_ms)
|
||||
|
||||
if packet:
|
||||
if lock: lock.acquire()
|
||||
try:
|
||||
filtered_packet = self._process_incoming_packet(packet)
|
||||
if filtered_packet:
|
||||
self.pending_rx_queue.append(filtered_packet)
|
||||
finally:
|
||||
if lock: lock.release()
|
||||
|
||||
# 2. Process incoming packet if received
|
||||
if packet:
|
||||
if lock: lock.acquire()
|
||||
try:
|
||||
filtered_packet = self._process_incoming_packet(packet)
|
||||
if filtered_packet:
|
||||
self.pending_rx_queue.append(filtered_packet)
|
||||
if msg_id in self.received_acks:
|
||||
self.received_acks.remove(msg_id)
|
||||
log(f"[ReliableLoRa] === ACK received for msg_id {msg_id} on attempt {attempt + 1} ===")
|
||||
return True
|
||||
finally:
|
||||
if lock: lock.release()
|
||||
|
||||
# 3. Check if matching ACK was received
|
||||
if lock: lock.acquire()
|
||||
try:
|
||||
if msg_id in self.received_acks:
|
||||
self.received_acks.remove(msg_id)
|
||||
log(f"[ReliableLoRa] === ACK received for msg_id {msg_id} on attempt {attempt + 1} ===")
|
||||
return True
|
||||
finally:
|
||||
if lock: lock.release()
|
||||
log(f"[ReliableLoRa] Attempt {attempt + 1} timed out waiting for ACK for msg_id {msg_id}")
|
||||
|
||||
log(f"[ReliableLoRa] Attempt {attempt + 1} timed out waiting for ACK for msg_id {msg_id}")
|
||||
|
||||
print(f"[ReliableLoRa] ERROR: Failed to receive ACK for msg_id {msg_id} after {max_retries} attempts.")
|
||||
return False
|
||||
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."""
|
||||
@@ -226,6 +231,13 @@ if IS_MICROPYTHON:
|
||||
payload = payload.encode('utf-8')
|
||||
|
||||
paquet_physique = bytes([group]) + payload
|
||||
|
||||
if len(paquet_physique) > self.MAX_PAYLOAD_SIZE:
|
||||
raise ValueError(
|
||||
f"[LoRa SPI] Payload exceeds MTU limit: {len(paquet_physique)} bytes "
|
||||
f"(max allowed: {self.MAX_PAYLOAD_SIZE} bytes)."
|
||||
)
|
||||
|
||||
try:
|
||||
self.lora.send(paquet_physique)
|
||||
except Exception as e:
|
||||
@@ -288,21 +300,31 @@ else:
|
||||
self.ser.reset_output_buffer()
|
||||
self.lock = threading.Lock()
|
||||
|
||||
# Initial configuration
|
||||
self._emergency_rx_buffer = []
|
||||
|
||||
self.configure(freq=868.1, sf=7, bw=125)
|
||||
|
||||
def _send_at_cmd(self, cmd, wait_time=0.3):
|
||||
"""Sends AT command, draining unread serial noise first."""
|
||||
# Drain any lingering lines (like 'Rssi= -4' or incoming data)
|
||||
if self.ser.in_waiting > 0:
|
||||
self.ser.read_all()
|
||||
|
||||
def _send_at_cmd(self, cmd, timeout=1.5):
|
||||
"""Sends AT command and safely handles async incoming packets."""
|
||||
self.ser.write(f"{cmd}\r\n".encode('utf-8'))
|
||||
time.sleep(wait_time)
|
||||
|
||||
start = time.time()
|
||||
resp = ""
|
||||
while self.ser.in_waiting > 0:
|
||||
resp += self.ser.readline().decode('utf-8', errors='ignore')
|
||||
while (time.time() - start) < timeout:
|
||||
if self.ser.in_waiting > 0:
|
||||
line = self.ser.readline().decode('utf-8', errors='ignore').strip()
|
||||
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if any(marker in line for marker in ["+RECV:", "+RCV=", "+DRX:", "(HEX:)", "Data:"]):
|
||||
self._emergency_rx_buffer.append(line)
|
||||
continue
|
||||
|
||||
resp += line + "\n"
|
||||
if "OK" in line or "ERROR" in line or "AT_BUSY" in line:
|
||||
break
|
||||
time.sleep(0.02)
|
||||
return resp
|
||||
|
||||
def configure(self, freq=868.1, sf=7, bw=125):
|
||||
@@ -313,14 +335,14 @@ else:
|
||||
|
||||
# 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)
|
||||
self._send_at_cmd(at_cfg_cmd, timeout=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._send_at_cmd("AT+SYNCWORD=18", timeout=0.1)
|
||||
self._send_at_cmd("AT+PRECV=65535", timeout=0.1)
|
||||
self.ser.reset_input_buffer()
|
||||
|
||||
def send(self, payload, group=None):
|
||||
def send(self, payload, group=None, retries=0):
|
||||
"""Encodes payload into a HEX string and transmits via 4-parameter AT+SEND."""
|
||||
with self.lock:
|
||||
if group is None:
|
||||
@@ -332,14 +354,20 @@ else:
|
||||
if isinstance(payload, str):
|
||||
payload = payload.encode('utf-8')
|
||||
|
||||
if len(payload) > self.MAX_PAYLOAD_SIZE:
|
||||
raise ValueError(
|
||||
f"[LoRa Serial] Payload exceeds MTU limit: {len(payload)} bytes "
|
||||
f"(max allowed: {self.MAX_PAYLOAD_SIZE} bytes)."
|
||||
)
|
||||
|
||||
hex_payload = payload.hex()
|
||||
|
||||
log(f"[RPi LoRa Serial] Transmitting HEX string: {hex_payload}")
|
||||
|
||||
# Format: AT+SEND=<group>,<payload_string>,<confirm>,<retries>
|
||||
cmd = f"AT+SEND={group},{hex_payload},0,3"
|
||||
cmd = f"AT+SEND={group},{hex_payload},0,{retries}"
|
||||
|
||||
resp = self._send_at_cmd(cmd, wait_time=0.3)
|
||||
resp = self._send_at_cmd(cmd, timeout=0.3)
|
||||
log(f"[RPi LoRa Serial] AT+SEND response: {resp}")
|
||||
|
||||
def receive_packet(self, timeout_ms=500):
|
||||
@@ -349,64 +377,70 @@ else:
|
||||
timeout_s = timeout_ms / 1000.0
|
||||
|
||||
while (time.time() - start_time) < timeout_s:
|
||||
if self.ser.in_waiting > 0:
|
||||
line = ""
|
||||
|
||||
# NEW: Process emergency buffer first before checking serial
|
||||
if self._emergency_rx_buffer:
|
||||
line = self._emergency_rx_buffer.pop(0)
|
||||
elif self.ser.in_waiting > 0:
|
||||
line = self.ser.readline().decode('utf-8', errors='ignore').strip()
|
||||
if line:
|
||||
payload_bytes = None
|
||||
|
||||
# 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)
|
||||
|
||||
if line:
|
||||
payload_bytes = None
|
||||
|
||||
# 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 "+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)
|
||||
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 "Data:" in line:
|
||||
payload_bytes = line.split("Data:")[1].strip().encode('utf-8')
|
||||
elif "(HEX:)" in line:
|
||||
hex_part = line.split("(HEX:)")[1].strip().replace(" ", "")
|
||||
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) > 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
|
||||
|
||||
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')
|
||||
except UnicodeError:
|
||||
continue
|
||||
|
||||
if text.startswith('{') or text.startswith('['):
|
||||
decoded_text = text
|
||||
elif text.lower().startswith('7b') or text.lower().startswith('5b'):
|
||||
try:
|
||||
text = payload_clean.decode('utf-8').strip('\x00 \r\n\t')
|
||||
except UnicodeError:
|
||||
continue
|
||||
|
||||
if text.startswith('{') or text.startswith('['):
|
||||
decoded_text = bytes.fromhex(text).decode('utf-8').strip('\x00 \r\n\t')
|
||||
except Exception:
|
||||
decoded_text = text
|
||||
elif text.lower().startswith('7b') or text.lower().startswith('5b'):
|
||||
try:
|
||||
decoded_text = bytes.fromhex(text).decode('utf-8').strip('\x00 \r\n\t')
|
||||
except Exception:
|
||||
decoded_text = text
|
||||
else:
|
||||
decoded_text = text
|
||||
|
||||
try:
|
||||
parsed_json = json.loads(decoded_text)
|
||||
return {"group": group, "data": parsed_json, "raw": False}
|
||||
except json.JSONDecodeError:
|
||||
return {"group": group, "data": decoded_text, "raw": True}
|
||||
else:
|
||||
decoded_text = text
|
||||
|
||||
try:
|
||||
parsed_json = json.loads(decoded_text)
|
||||
return {"group": group, "data": parsed_json, "raw": False}
|
||||
except json.JSONDecodeError:
|
||||
return {"group": group, "data": decoded_text, "raw": True}
|
||||
|
||||
time.sleep(0.01)
|
||||
return None
|
||||
@@ -423,5 +457,9 @@ def get_lora_device(port_or_pins=None):
|
||||
|
||||
class LoraCommands:
|
||||
PING = "ping"
|
||||
MICROVAVE_STATE_UPDATE = "microwave_state_update"
|
||||
COOKING_STATE_UPDATE = "cooking_state_update"
|
||||
TOGGLE_PAUSE = "toggle_pause"
|
||||
COOKING_UPDATE = "cooking_update"
|
||||
TOGGLE_PAUSE = "toggle_pause"
|
||||
TOGGLE_DEFROST = "toggle_defrost"
|
||||
NEW_ALERT = "new_alert"
|
||||
@@ -0,0 +1,95 @@
|
||||
from shared.cookingState import CookingStates
|
||||
import shared.config as config
|
||||
|
||||
class MicrowaveStateFields:
|
||||
STATE = "state"
|
||||
PAUSED = "paused"
|
||||
COOKING_STATE = "cooking_state"
|
||||
COOKING_ESTIMATED_REMAINING_TIME = "cooking_estimated_remaining_time"
|
||||
COOKING_START_TIME = "cooking_start_time"
|
||||
|
||||
class MicrowaveState:
|
||||
state = None
|
||||
paused = False
|
||||
cooking_state = None
|
||||
cooking_estimated_remaining_time = 0
|
||||
cooking_start_time = 0
|
||||
|
||||
def __init__(self, id, on_change_callback=None):
|
||||
self.friendly_name = f"Microwave-{id}"
|
||||
self.state = MicrowaveState.IDLE
|
||||
self.cooking_state = CookingStates.IDLE
|
||||
self.on_change_callback = on_change_callback
|
||||
|
||||
def _notify_change(self, field: MicrowaveStateFields = None):
|
||||
"""Invokes callback if registered on state attribute changes."""
|
||||
if self.on_change_callback:
|
||||
try:
|
||||
self.on_change_callback(self, field)
|
||||
except Exception as e:
|
||||
print(f"[MicrowaveState] Error in change callback: {e}")
|
||||
|
||||
def set_state(self, new_state):
|
||||
if config.DEBUG:
|
||||
assert new_state in (
|
||||
MicrowaveState.IDLE,
|
||||
MicrowaveState.ANALYZING,
|
||||
MicrowaveState.WAITING_FOR_CLOUD,
|
||||
MicrowaveState.COOKING,
|
||||
MicrowaveState.DONE,
|
||||
MicrowaveState.ALERT,
|
||||
), f"Invalid state: {new_state}"
|
||||
|
||||
if self.state != new_state:
|
||||
self.state = new_state
|
||||
self._notify_change(MicrowaveStateFields.STATE)
|
||||
|
||||
def set_paused(self, is_paused):
|
||||
if self.paused != is_paused:
|
||||
self.paused = is_paused
|
||||
self._notify_change(MicrowaveStateFields.PAUSED)
|
||||
|
||||
def toggle_pause(self):
|
||||
self.set_paused(not self.paused)
|
||||
|
||||
def set_cooking_state(self, new_cooking_state):
|
||||
if config.DEBUG:
|
||||
assert new_cooking_state in (
|
||||
CookingStates.IDLE,
|
||||
CookingStates.COOKING,
|
||||
CookingStates.DONE,
|
||||
CookingStates.STIRRING_REQUIRED,
|
||||
CookingStates.ALERT
|
||||
), "Invalid cooking state: " + str(new_cooking_state)
|
||||
|
||||
if self.cooking_state != new_cooking_state:
|
||||
if self.cooking_state == CookingStates.COOKING and new_cooking_state == CookingStates.DONE:
|
||||
self.set_state(MicrowaveState.DONE)
|
||||
self.cooking_state = new_cooking_state
|
||||
self._notify_change(MicrowaveStateFields.COOKING_STATE)
|
||||
|
||||
def set_cooking_estimated_remaining_time(self, time_seconds):
|
||||
if self.cooking_estimated_remaining_time != time_seconds:
|
||||
self.cooking_estimated_remaining_time = time_seconds
|
||||
self._notify_change(MicrowaveStateFields.COOKING_ESTIMATED_REMAINING_TIME)
|
||||
|
||||
def set_cooking_start_time(self, start_time):
|
||||
if self.cooking_start_time != start_time:
|
||||
self.cooking_start_time = start_time
|
||||
self._notify_change(MicrowaveStateFields.COOKING_START_TIME)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"friendly_name": self.friendly_name,
|
||||
"state": self.state,
|
||||
"paused": self.paused,
|
||||
"cooking_estimated_remaining_time": self.cooking_estimated_remaining_time,
|
||||
"cooking_start_time": self.cooking_start_time
|
||||
}
|
||||
|
||||
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
|
||||
ALERT = "ALERT" # Alert state, waiting for user action
|
||||
+50
-1
@@ -1,5 +1,7 @@
|
||||
from time import time
|
||||
from shared.deviceTypes import DEVICE_TYPES
|
||||
from shared.alerts import Alert, AlertType
|
||||
import shared.config as config
|
||||
|
||||
|
||||
try:
|
||||
@@ -39,10 +41,57 @@ def mqtt_sensor_data(id_microwave, dish_temp, ambient_temp):
|
||||
"ambient_temp": ambient_temp
|
||||
})
|
||||
|
||||
def lora_sensor_data(dish_temp, ambient_temp):
|
||||
return as_json({
|
||||
"dish_temp": dish_temp,
|
||||
"ambient_temp": ambient_temp
|
||||
})
|
||||
|
||||
def mqtt_cooking_config(id_microwave, cook_time, power_level, target_temp):
|
||||
return as_json({
|
||||
"id_microwave": id_microwave,
|
||||
"cook_time": cook_time,
|
||||
"power_level": power_level,
|
||||
"target_temp": target_temp
|
||||
})
|
||||
})
|
||||
|
||||
def telemetry_payload(device_id, microwave_states, button_state, cloud_alert, gps_data, ambient_temp, ambient_humidity, connected_components, logs):
|
||||
return as_json({
|
||||
"device_id": device_id,
|
||||
"timestamp": int(time()),
|
||||
"states": microwave_states,
|
||||
"button_state": button_state,
|
||||
"cloud_alert": cloud_alert,
|
||||
"gps": gps_data,
|
||||
"ambient_temp": ambient_temp,
|
||||
"ambient_humidity": ambient_humidity,
|
||||
"connected_components": connected_components,
|
||||
"logs": logs
|
||||
})
|
||||
|
||||
def lora_new_alert(alert: Alert, with_message=False):
|
||||
try:
|
||||
from shared.lora_device import LoraCommands
|
||||
except ImportError:
|
||||
pass # No need
|
||||
|
||||
payload = {
|
||||
"action": LoraCommands.NEW_ALERT,
|
||||
"alert_type": alert.alertType
|
||||
}
|
||||
|
||||
if with_message:
|
||||
payload["alert_message"] = alert.message
|
||||
|
||||
return payload
|
||||
|
||||
def lora_microwave_state(state: str):
|
||||
try:
|
||||
from shared.lora_device import LoraCommands
|
||||
except ImportError:
|
||||
pass # No need
|
||||
|
||||
return {
|
||||
"action": LoraCommands.MICROVAVE_STATE_UPDATE,
|
||||
"new_microwave_state": state
|
||||
}
|
||||
@@ -75,6 +75,8 @@ class RGBLED:
|
||||
"""Sets the RGB color tuple (e.g., (255, 128, 0))."""
|
||||
if isinstance(rgb_tuple, (tuple, list)) and len(rgb_tuple) == 3:
|
||||
self._color = tuple(rgb_tuple)
|
||||
# Automatically turn LED on unless color is OFF (0,0,0)
|
||||
self._is_on = (self._color != self.OFF)
|
||||
self._apply()
|
||||
else:
|
||||
raise ValueError("Color must be a tuple of 3 integers: (R, G, B)")
|
||||
@@ -144,6 +146,7 @@ class RGBLED:
|
||||
if self._is_blinking:
|
||||
self._timer.deinit()
|
||||
self._is_blinking = False
|
||||
self.on() # Ensures LED remains ON steadily after blinking stops
|
||||
|
||||
def blink_toggle(self, interval_ms=500):
|
||||
"""Toggles blinking state (starts if stopped, stops if active)."""
|
||||
|
||||
+3
-1
@@ -97,4 +97,6 @@ class UARTCommand:
|
||||
class UARTCommandType:
|
||||
"""Enumeration of known UART command types."""
|
||||
COOKING_PARAMS = "COOKING_PARAMS"
|
||||
COOKING_STATE_UPDATE = "COOKING_STATE_UPDATE"
|
||||
COOKING_STATE_UPDATE = "COOKING_STATE_UPDATE"
|
||||
TEMPERATURE_REQUEST = "TEMPERATURE_REQUEST"
|
||||
TEMPERATURE_RESPONSE = "TEMPERATURE_RESPONSE"
|
||||
Reference in New Issue
Block a user