Compare commits

..

109 Commits

Author SHA1 Message Date
Ninluc e68ffd9b8b Fix
Build, push image, and notify Watchtower / build-image (push) Successful in 43s
Build, push image, and notify Watchtower / notify (push) Successful in 10s
2026-08-25 20:07:38 +02:00
Ninluc 49fd566457 Idk
Build, push image, and notify Watchtower / build-image (push) Successful in 48s
Build, push image, and notify Watchtower / notify (push) Successful in 10s
2026-08-25 20:01:36 +02:00
Ninluc dd37465546 OOpsie
Build, push image, and notify Watchtower / build-image (push) Successful in 50s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-25 19:23:10 +02:00
Ninluc 3753f57041 Small things because fuck sd cards
Build, push image, and notify Watchtower / build-image (push) Successful in 3m22s
Build, push image, and notify Watchtower / notify (push) Successful in 1m24s
2026-08-25 19:16:50 +02:00
Ninluc 688fd26db8 Changed interface 2026-08-25 19:16:41 +02:00
Ninluc 4d7706f8e1 Adde command to setuot thread network 2026-08-25 19:16:13 +02:00
Ninluc efbc147f48 Default debug to false 2026-08-25 19:16:01 +02:00
Ninluc 4085305039 Save Analyzed image 2026-08-25 19:15:52 +02:00
Ninluc fed99772fc Don't override microwave ID
Build, push image, and notify Watchtower / build-image (push) Successful in 46s
Build, push image, and notify Watchtower / notify (push) Successful in 8s
2026-08-24 15:44:22 +02:00
Ninluc 204f271fc2 Fix global
Build, push image, and notify Watchtower / build-image (push) Successful in 42s
Build, push image, and notify Watchtower / notify (push) Successful in 14s
2026-08-23 20:04:26 +02:00
Ninluc 5c60de3f03 Fix buzzer 2026-08-23 20:04:20 +02:00
Ninluc 8a71d4eaa7 :(
Build, push image, and notify Watchtower / build-image (push) Successful in 37s
Build, push image, and notify Watchtower / notify (push) Successful in 12s
2026-08-23 18:52:05 +02:00
Ninluc eb80d13300 :(
Build, push image, and notify Watchtower / build-image (push) Successful in 39s
Build, push image, and notify Watchtower / notify (push) Successful in 12s
2026-08-23 18:43:16 +02:00
Ninluc 6e2f849ee2 :(
Build, push image, and notify Watchtower / build-image (push) Successful in 39s
Build, push image, and notify Watchtower / notify (push) Successful in 19s
2026-08-23 18:38:41 +02:00
Ninluc 68250e8069 IDC
Build, push image, and notify Watchtower / build-image (push) Successful in 51s
Build, push image, and notify Watchtower / notify (push) Successful in 12s
2026-08-23 18:30:00 +02:00
Ninluc fe31ac978a Dissociate DEBUG variables 2026-08-23 17:48:04 +02:00
Ninluc 3c50fc4d0e Follow the damn prompt CJ
Build, push image, and notify Watchtower / build-image (push) Successful in 39s
Build, push image, and notify Watchtower / notify (push) Successful in 10s
2026-08-23 17:45:13 +02:00
Ninluc 3aed0e5f00 Fuck the blur
Build, push image, and notify Watchtower / build-image (push) Successful in 42s
Build, push image, and notify Watchtower / notify (push) Successful in 16s
2026-08-23 17:36:57 +02:00
Ninluc f55336a636 Tried my own
Build, push image, and notify Watchtower / build-image (push) Successful in 47s
Build, push image, and notify Watchtower / notify (push) Successful in 15s
2026-08-23 17:33:24 +02:00
Ninluc c58ae4603e Skip sending implicit new state
Build, push image, and notify Watchtower / build-image (push) Successful in 39s
Build, push image, and notify Watchtower / notify (push) Successful in 11s
2026-08-23 17:07:18 +02:00
Ninluc e7f6374200 Skip food safety alert for webex and sms 2026-08-23 17:06:59 +02:00
Ninluc 9053351674 idk
Build, push image, and notify Watchtower / build-image (push) Successful in 37s
Build, push image, and notify Watchtower / notify (push) Successful in 9s
2026-08-23 16:51:45 +02:00
Ninluc 1f53e7e6c3 Better prompt again ?
Build, push image, and notify Watchtower / build-image (push) Successful in 34s
Build, push image, and notify Watchtower / notify (push) Successful in 15s
2026-08-23 16:51:27 +02:00
Ninluc 7c35b52dbd Precision in prompt + better camera
Build, push image, and notify Watchtower / build-image (push) Successful in 38s
Build, push image, and notify Watchtower / notify (push) Successful in 11s
2026-08-23 16:48:30 +02:00
Ninluc 0f576467e1 send_reliably if cooking state update
Build, push image, and notify Watchtower / build-image (push) Successful in 40s
Build, push image, and notify Watchtower / notify (push) Successful in 15s
2026-08-23 16:19:09 +02:00
Ninluc 9d1a1f4c78 Try with normal image 2026-08-23 16:18:53 +02:00
Ninluc 701e7f1491 Reapply "Fix move to new messages"
This reverts commit e652831ce5.
2026-08-23 16:14:04 +02:00
Ninluc e652831ce5 Revert "Fix move to new messages"
This reverts commit 3bead59b23.
2026-08-23 16:12:15 +02:00
Ninluc ebc28546bc Better camera settings 2026-08-23 16:10:11 +02:00
Ninluc a51bfc1388 Bigger gaps 2026-08-23 16:10:00 +02:00
Ninluc 7082ea3f8b Better camera settings + better rpompt
Build, push image, and notify Watchtower / build-image (push) Successful in 45s
Build, push image, and notify Watchtower / notify (push) Successful in 12s
2026-08-23 14:32:17 +02:00
Ninluc e802386adf Set to tidle when error requesting cooking plan 2026-08-23 14:22:59 +02:00
Ninluc 289124b7f8 Change State when microwave state is Idel 2026-08-23 14:22:39 +02:00
Ninluc 3bead59b23 Fix move to new messages 2026-08-23 13:04:16 +02:00
Ninluc 467740ba84 Better prompt
Build, push image, and notify Watchtower / build-image (push) Successful in 40s
Build, push image, and notify Watchtower / notify (push) Successful in 9s
2026-08-23 12:51:55 +02:00
Ninluc 29c943ca5a Smooth image to remove noise
Build, push image, and notify Watchtower / build-image (push) Successful in 38s
Build, push image, and notify Watchtower / notify (push) Successful in 11s
2026-08-23 12:51:14 +02:00
Ninluc c130566156 Accept microwaves state IDLE 2026-08-23 12:51:05 +02:00
Ninluc 178457e199 Fix imports for micropython 2026-08-23 12:50:50 +02:00
Ninluc c860afc609 Changed IDs 2026-08-23 12:50:27 +02:00
Ninluc 9562bb7315 Forgot to remove that
Build, push image, and notify Watchtower / build-image (push) Successful in 1m19s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-23 12:39:44 +02:00
Ninluc a769efa8e0 Fix import
Build, push image, and notify Watchtower / build-image (push) Successful in 1m7s
Build, push image, and notify Watchtower / notify (push) Successful in 18s
2026-08-23 12:37:06 +02:00
Ninluc 3711928c7d Enhance image before ai processing
Build, push image, and notify Watchtower / build-image (push) Successful in 41s
Build, push image, and notify Watchtower / notify (push) Successful in 14s
2026-08-23 12:33:39 +02:00
Ninluc 6b8c1688a6 Send alerts and reset 2026-08-23 12:33:25 +02:00
Ninluc 755ffdad46 Send as an alert
Build, push image, and notify Watchtower / build-image (push) Successful in 55s
Build, push image, and notify Watchtower / notify (push) Successful in 14s
2026-08-23 11:32:42 +02:00
Ninluc 29fcb26eae No privilegied container 2026-08-23 11:32:34 +02:00
Ninluc 8b3c1d6b63 Added Fake bowl dish name for documentation 2026-08-23 11:18:29 +02:00
Ninluc c39b811a3c Lower noise buffer for ultrasonic ranger
Build, push image, and notify Watchtower / build-image (push) Successful in 41s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-23 11:05:04 +02:00
Ninluc b0a6c4d2ec Bettter safety check 2026-08-23 11:04:38 +02:00
Ninluc 0b1971ed0b Forgot https 2026-08-23 10:00:20 +02:00
Ninluc d6951cbbcf Fix Camera cm per pixels 2026-08-23 10:00:15 +02:00
Ninluc c6cec3dede add to non-authenticated routes
Build, push image, and notify Watchtower / build-image (push) Successful in 34s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-23 09:36:08 +02:00
Ninluc e67a13b4c8 Fix jobs ?
Build, push image, and notify Watchtower / build-image (push) Successful in 36s
Build, push image, and notify Watchtower / notify (push) Successful in 15s
2026-08-23 09:30:31 +02:00
Ninluc 0d394eca1e Only use Segoie UI 2026-08-23 09:27:54 +02:00
Ninluc c76379230b Remove false positives 2026-08-23 09:27:44 +02:00
Ninluc e042162a77 Fix api server host 2026-08-23 09:27:34 +02:00
Ninluc f167f4a2cd Set final product physical properties 2026-08-23 09:27:19 +02:00
Ninluc 7add88f154 Added logo file 2026-08-23 09:24:53 +02:00
Ninluc bb5327fb93 Technician Debug Web Server
Build, push image, and notify Watchtower / build-image (push) Successful in 42s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-19 17:04:33 +02:00
Ninluc 022c45edee Fix API key 2026-08-19 17:04:10 +02:00
Ninluc d7833100a5 RFID reading and authorization
Build, push image, and notify Watchtower / build-image (push) Successful in 43s
Build, push image, and notify Watchtower / notify (push) Successful in 12s
2026-08-19 15:51:13 +02:00
Ninluc db173af1c9 Only node-red is allowed to run CVE audit 2026-08-19 15:47:44 +02:00
Ninluc d0066a9d6d Authentificate to API
Build, push image, and notify Watchtower / build-image (push) Successful in 40s
Build, push image, and notify Watchtower / notify (push) Successful in 8s
2026-08-18 18:40:32 +02:00
Ninluc f9309c4dbc Authentication and Odata compliance
Build, push image, and notify Watchtower / build-image (push) Successful in 39s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-18 17:04:02 +02:00
Ninluc e69bbc6b45 Debug jobs
Build, push image, and notify Watchtower / build-image (push) Successful in 44s
Build, push image, and notify Watchtower / notify (push) Successful in 10s
2026-08-18 15:50:15 +02:00
Ninluc 88c3de6ab7 Shodan and jobs
Build, push image, and notify Watchtower / build-image (push) Successful in 3m25s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-18 15:48:37 +02:00
Ninluc 2c0776bdaa Logs the read GPS line
Build, push image, and notify Watchtower / build-image (push) Successful in 1m3s
Build, push image, and notify Watchtower / notify (push) Successful in 12s
2026-08-18 13:19:02 +02:00
Ninluc da93eaa604 Added dashboard link 2026-08-18 13:18:50 +02:00
Ninluc 608e07cb92 We rolling back ? 2026-08-17 23:21:21 +02:00
Ninluc 2ee70861cd Better GPS read 2026-08-17 23:14:18 +02:00
Ninluc af556fde58 Wait more than 2 seconds for better DHT sensor reads 2026-08-17 23:14:10 +02:00
Ninluc bcc3670aff Do not timeout if only asked to you
Build, push image, and notify Watchtower / build-image (push) Successful in 40s
Build, push image, and notify Watchtower / notify (push) Successful in 8s
2026-08-17 23:03:34 +02:00
Ninluc ab8199344c Disable temperature alert
Build, push image, and notify Watchtower / build-image (push) Successful in 44s
Build, push image, and notify Watchtower / notify (push) Successful in 14s
2026-08-17 22:20:22 +02:00
Ninluc 5a3f1d94ef Fix datetime function 2026-08-17 22:20:12 +02:00
Ninluc 50eb4fe87c Fix bug
Build, push image, and notify Watchtower / build-image (push) Successful in 48s
Build, push image, and notify Watchtower / notify (push) Successful in 11s
2026-08-17 17:28:47 +02:00
Ninluc 44a6be9090 Use added alert message 2026-08-17 17:28:41 +02:00
Ninluc 9ef4b584a4 Removed logs and hardcoded strings
Build, push image, and notify Watchtower / build-image (push) Successful in 37s
Build, push image, and notify Watchtower / notify (push) Successful in 11s
2026-08-17 17:11:46 +02:00
Ninluc 4234e1ed21 Debug
Build, push image, and notify Watchtower / build-image (push) Successful in 1m3s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-17 17:06:01 +02:00
Ninluc d39c5c26e7 Fix auth token
Build, push image, and notify Watchtower / build-image (push) Successful in 1m19s
Build, push image, and notify Watchtower / notify (push) Successful in 18s
2026-08-17 17:03:06 +02:00
Ninluc 9d609a367b Harcode from
Build, push image, and notify Watchtower / build-image (push) Successful in 1m14s
Build, push image, and notify Watchtower / notify (push) Successful in 17s
2026-08-17 17:00:07 +02:00
Ninluc b31d939787 Force phone number
Build, push image, and notify Watchtower / build-image (push) Successful in 35s
Build, push image, and notify Watchtower / notify (push) Successful in 11s
2026-08-17 16:50:55 +02:00
Ninluc 647e40131c Can't use my own body :(
Build, push image, and notify Watchtower / build-image (push) Successful in 44s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-17 16:47:35 +02:00
Ninluc c049339003 Fix boolean env variables
Build, push image, and notify Watchtower / build-image (push) Successful in 43s
Build, push image, and notify Watchtower / notify (push) Successful in 11s
2026-08-17 16:17:28 +02:00
Ninluc 3ba65f0106 Removed logging
Build, push image, and notify Watchtower / build-image (push) Successful in 38s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-17 16:07:40 +02:00
Ninluc ffbb350520 twilio small cleanup
Build, push image, and notify Watchtower / build-image (push) Successful in 46s
Build, push image, and notify Watchtower / notify (push) Successful in 12s
2026-08-17 16:01:33 +02:00
Ninluc 656afa6e2d Fix webex room url
Build, push image, and notify Watchtower / build-image (push) Successful in 39s
Build, push image, and notify Watchtower / notify (push) Successful in 11s
2026-08-17 15:57:16 +02:00
Ninluc 8f768a4d35 Returns and save the room link
Build, push image, and notify Watchtower / build-image (push) Successful in 41s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-17 15:46:36 +02:00
Ninluc 2047b1b522 Forgot dependency
Build, push image, and notify Watchtower / build-image (push) Successful in 4m33s
Build, push image, and notify Watchtower / notify (push) Successful in 14s
2026-08-17 15:26:37 +02:00
Ninluc 3db52f1cad send SMS
Build, push image, and notify Watchtower / build-image (push) Successful in 44s
Build, push image, and notify Watchtower / notify (push) Successful in 12s
2026-08-17 15:19:35 +02:00
Ninluc 5ae2dc1023 Display webex oauth errors
Build, push image, and notify Watchtower / build-image (push) Successful in 41s
Build, push image, and notify Watchtower / notify (push) Successful in 11s
2026-08-17 14:53:37 +02:00
Ninluc 44b4c8034f better Webex
Build, push image, and notify Watchtower / build-image (push) Successful in 42s
Build, push image, and notify Watchtower / notify (push) Successful in 12s
2026-08-17 14:46:35 +02:00
Ninluc 0149c1e5b2 Fix needed import
Build, push image, and notify Watchtower / build-image (push) Successful in 44s
Build, push image, and notify Watchtower / notify (push) Successful in 16s
2026-08-17 14:22:27 +02:00
Ninluc b442d39c18 Webex first draft
Build, push image, and notify Watchtower / build-image (push) Successful in 1m0s
Build, push image, and notify Watchtower / notify (push) Successful in 17s
2026-08-17 14:21:02 +02:00
Ninluc 8b6ab086fd better alerts and connected_components
Build, push image, and notify Watchtower / build-image (push) Successful in 57s
Build, push image, and notify Watchtower / notify (push) Successful in 9s
2026-08-17 13:45:51 +02:00
Ninluc 2d8889cd31 HTTPS url
Build, push image, and notify Watchtower / build-image (push) Successful in 49s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-17 13:14:23 +02:00
Ninluc 83b269cc88 Temperature Alert
Build, push image, and notify Watchtower / build-image (push) Successful in 1m17s
Build, push image, and notify Watchtower / notify (push) Successful in 16s
2026-08-17 13:04:35 +02:00
Ninluc 01eccf47c6 Better LoRa 2026-08-16 16:28:58 +02:00
Ninluc 9b0ecf944a Fix microwave not resetting 2026-08-16 16:28:51 +02:00
Ninluc c6e56b58a0 Send microwave states to lora 2026-08-16 15:50:57 +02:00
Ninluc 1a77794fd3 Moved to shared 2026-08-16 15:42:11 +02:00
Ninluc 0469b3d406 Fixed temphum sensor
Build, push image, and notify Watchtower / build-image (push) Successful in 43s
Build, push image, and notify Watchtower / notify (push) Successful in 17s
2026-08-16 14:23:28 +02:00
Ninluc fd00c9a89d Sending updates to external screen + RGB LCD 2026-08-16 14:23:18 +02:00
Ninluc 2276c1742a Removed wrongly used field 2026-08-16 14:21:50 +02:00
Ninluc 32229de20d ESP's Changed id ? 2026-08-16 14:21:30 +02:00
Ninluc d9b596d465 Fix import error 2026-08-16 14:21:20 +02:00
Ninluc 36abfc8234 Better prompt ?
Build, push image, and notify Watchtower / build-image (push) Successful in 46s
Build, push image, and notify Watchtower / notify (push) Successful in 18s
2026-08-15 18:50:54 +02:00
Ninluc 07ce941950 Better prompt
Build, push image, and notify Watchtower / build-image (push) Successful in 47s
Build, push image, and notify Watchtower / notify (push) Successful in 25s
2026-08-15 18:47:46 +02:00
Ninluc f6c714b1c3 Prompt for more relaxed checks
Build, push image, and notify Watchtower / build-image (push) Successful in 37s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-15 18:45:11 +02:00
Ninluc 86815a9d4d Fake photo when debugging
Build, push image, and notify Watchtower / build-image (push) Successful in 40s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-15 18:36:18 +02:00
Ninluc e132d05a85 Pretty display 2026-08-15 15:00:22 +02:00
47 changed files with 2996 additions and 754 deletions
+2
View File
@@ -2,3 +2,5 @@
`export PYTHONPATH=/home/pi/SmartWave`
`export PYTHONPATH=/home/ninluc/Documents/school/IoT/smartWave`
https://red.smartwave.matthiasg.dev/dashboard
+9 -9
View File
@@ -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)
SAVEUP_EDAMAM_API_TOKEN = os.getenv("SAVEUP_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)"""
@@ -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
)
+134
View File
@@ -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")
}
+47
View File
@@ -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
+147
View File
@@ -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)
+40
View File
@@ -2,3 +2,43 @@
in `/cloud` folder :
`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
+547 -77
View File
@@ -1,16 +1,26 @@
import os
import asyncio
import base64
import uuid
import datetime
import sys
import json
from flask import Flask, request, jsonify, current_app
from pymongo import MongoClient
from APIs import generate, EdamamAPI
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:
@@ -29,150 +39,521 @@ 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"]
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/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"])
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
if not data:
return jsonify({"error": "Invalid or missing JSON payload"}), 400
# 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)
})
# Extract user or device parameters
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))
# 1. Save Camera Image
camera_image_b64 = data.get("camera_image")
if not camera_image_b64:
return jsonify({"error": "Missing required field 'camera_image'"}), 400
return jsonify({"@odata.error": {"code": "400", "message": "Missing camera_image"}}), 400
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:
current_app.logger.exception("Failed to save camera image")
return jsonify({"error": f"Failed to save camera image: {str(e)}"}), 500
with open(filepath, "wb") as f:
f.write(base64.b64decode(camera_image_b64))
data["camera_image"] = filepath
# 2. Run Vision Safety Check & Cook Planner Concurrently
try:
if getattr(config, "DEBUG_DANGEROUS_AREA", False):
print(f"[Debug] Running safety check on predefined image")
safety_task = asyncio.to_thread(safety_checker.check_dish_safety, os.path.join(CAMERA_IMAGE_DIR, "dish_0de0ee1dab8949fc8e78796947e24ed3.jpg"))
else:
safety_task = asyncio.to_thread(safety_checker.check_dish_safety, filepath)
planner_task = asyncio.to_thread(
microwave_cook_planner.generate_plan,
image_path=filepath,
height_cm=height_cm,
initial_temp_c=initial_temp_c,
microwave_wattage=microwave_wattage,
defrost_mode=defrost_mode
)
enhanced_filepath = enhance_image_for_ai(filepath)
# Execute both concurrently and await results
safety_result, cook_plan = await asyncio.gather(safety_task, planner_task)
# 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
)
except Exception as e:
current_app.logger.exception("Exception during safety check or cook planning")
return jsonify({"error": f"Task execution failed: {str(e)}"}), 500
safety_result, cook_plan = await asyncio.gather(safety_task, planner_task)
# 4. Attach Cooking Plan & safety check and Save to MongoDB
data["safety_check"] = safety_result
data["analysis_results"] = cook_plan
try:
cooking_collection.insert_one(data)
data.pop("_id", None)
except Exception as e:
return jsonify({"error": f"Database error: {str(e)}"}), 500
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
}
# 3. Evaluate Safety Result
if not safety_result.get("is_safe", True):
current_app.logger.warning(f"Unsafe dish detected: {safety_result}")
return jsonify({
"error": "Safety hazard detected in microwave area",
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", [])
}), 200
})
return jsonify(odata_response), 200
return jsonify(odata_response), 201
return jsonify(cook_plan), 201
except Exception as e:
current_app.logger.exception("Exception in /cooking-params")
current_app.logger.exception("Error in /cooking-params")
return jsonify({"@odata.error": {"code": "500", "message": "Internal processing error"}}), 500
@app.route("/alert", methods=["POST"])
def alert():
# Save the alert in alert_collection
data = request.get_json()
alert_collection.insert_one(data)
if data is None or not isinstance(data, dict):
return jsonify({
"@odata.error": {
"code": "400",
"message": "Invalid or missing JSON payload"
}
}), 400
# TODO : Créer une room webex et envoyer un sms
# 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)
return jsonify({"status": "success", "message": "Alert saved"}), 200
try:
saved_alert = alert_collection.insert_one(data)
doc_id = str(saved_alert.inserted_id)
except Exception as e:
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
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({"error": "Invalid or missing JSON payload"}), 400
return jsonify({"@odata.error": {"code": "400", "message": "Invalid JSON payload"}}), 400
# If the payload was double-encoded as a string, deserialize it
if isinstance(data, str):
try:
data = json.loads(data)
except (json.JSONDecodeError, TypeError):
return jsonify({"error": "String payload could not be parsed as JSON"}), 400
return jsonify({"@odata.error": {"code": "400", "message": "String payload invalid"}}), 400
if not isinstance(data, dict):
return jsonify({"error": "Expected a JSON object/dictionary"}), 400
# Stamp UTC timestamp for Node-RED queries
data["received_at"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
data["device_id"] = g.device_id
data["received_at"] = datetime.now(timezone.utc).isoformat()
try:
telemetry_collection.insert_one(data)
return jsonify({"status": "success", "message": "Telemetry saved"}), 200
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:
return jsonify({"error": f"Database error: {str(e)}"}), 500
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('/')}/telemetry"
telemetry_url = f"{request.host_url.rstrip('/').replace('http://', 'https://')}/telemetry"
cmd_payload = {
"action": "request_telemetry",
@@ -188,10 +569,99 @@ def debug_telemetryrequest():
except Exception as e:
return jsonify({"error": f"Failed to publish MQTT command: {str(e)}"}), 500
@app.route("/debug", methods=["GET"])
def debug():
return safety_checker.check_dish_safety(os.path.join(CAMERA_IMAGE_DIR, "dish_0de0ee1dab8949fc8e78796947e24ed3.jpg"))
@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=getattr(config, "DEBUG", True))
app.run(debug=getattr(config, "DEBUG", False))
+26
View File
@@ -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
+84
View File
@@ -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)}")
+18 -2
View File
@@ -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)
+41
View File
@@ -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)]
}
+2
View File
@@ -6,3 +6,5 @@ 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
+15 -20
View File
@@ -3,49 +3,44 @@ from pydantic import BaseModel, Field
from APIs.aichat import generate
import shared.config as config
# 1. Define the output schema as a Pydantic model
class DishSafetyResult(BaseModel):
visible_objects: list[str] = Field(
description="List all distinct physical objects visible in or around the dish (e.g., bowl, liquid, spoon, cover)."
)
material_analysis: str = Field(
description="Analyze the physical material of each visible object (e.g., ceramic, stainless steel, glass, flexible film)."
)
detected_hazards: list[str] = Field(
description="List only the items from visible_objects made of metal, metallic foil/trim, or sealed plastic. Empty if none."
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 set to True ONLY if detected_hazards is empty. Otherwise False."
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="One short sentence explaining the hazard if detected_hazards is not empty, otherwise an empty string."
description="Warning statement if unsafe, otherwise empty string ''."
)
def check_dish_safety(image_path: str) -> dict:
prompt = (
"Examine this top-down photo of a dish intended for a microwave. "
"Carefully inspect all visible objects and their surface materials to determine if any microwave safety hazards exist."
"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."
)
# 2. Pass the Pydantic class directly to generate()
response_raw = generate(
prompt=prompt,
images=[image_path],
output_format=DishSafetyResult,
should_think=False,
)
print(f"[Debug] Raw response from AI generator: {response_raw}")
# 3. Handle response parsing
if config.DEBUG_MESSAGES:
print(f"[Debug] Raw response from AI generator: {response_raw}")
if isinstance(response_raw, str):
# Parse and validate the JSON string into the Pydantic model, then return as a dict
try:
validated_result = DishSafetyResult.model_validate_json(response_raw)
return validated_result.model_dump()
except Exception:
# Fallback to standard json.loads if raw parsing is needed
return json.loads(response_raw)
if isinstance(response_raw, DishSafetyResult):
+2 -2
View File
@@ -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
+16
View File
@@ -1,3 +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
+42 -10
View File
@@ -4,16 +4,33 @@
#include "esphome/components/json/json_util.h"
#include <functional>
#include <string>
#include <vector>
// Callback signature: void(device_id, cloud_alert, defrost_mode)
using DisplayUpdateCallback = std::function<void(const std::string &device_id, bool cloud_alert, bool defrost_mode)>;
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 {
return request->url_to() == "/api/display";
// 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 {
@@ -24,16 +41,31 @@ class DisplayApiHandler : public esphome::web_server_idf::AsyncWebHandler {
std::string body((char *)data, len);
esphome::json::parse_json(body, [](JsonObject root) -> bool {
std::string device_id = root["device_id"] | "unknown";
bool cloud_alert = root["cloud_alert"] | false;
bool defrost_mode = root["defrost_mode"] | false;
g_cloud_alert = root["cloud_alert"] | false;
g_update_time = root["update_time"] | "--:--";
ESP_LOGI("custom_api", "Received update for %s (Alert: %d, Defrost: %d)",
device_id.c_str(), cloud_alert, defrost_mode);
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());
// Execute the callback passed from YAML
if (g_display_callback != nullptr) {
g_display_callback(device_id, cloud_alert, defrost_mode);
g_display_callback();
}
return true;
+77 -60
View File
@@ -10,30 +10,11 @@ esphome:
priority: -100.0
then:
- lambda: |-
setup_display_api([](const std::string &dev_id, bool alert, bool defrost) {
// Update global state
id(cloud_alert) = alert;
id(defrost_mode) = defrost;
// Set status message text
std::string msg = "Microwave " + dev_id;
id(status_message).publish_state(msg.c_str());
setup_display_api([]() {
// Force immediate e-Paper refresh!
id(my_display).update();
});
# -------------------------------------------------------------------
# State Globals
# -------------------------------------------------------------------
globals:
- id: cloud_alert
type: bool
initial_value: 'false'
- id: defrost_mode
type: bool
initial_value: 'false'
web_server:
port: 5000
@@ -48,7 +29,6 @@ mdns:
type: "epaper_2.13"
esp32:
board: epaper-node-display
variant: esp32h2
framework:
type: esp-idf
@@ -102,22 +82,13 @@ openthread:
# Template Text Sensor
# -------------------------------------------------------------------
text_sensor:
- platform: template
name: "Display Status Message"
id: status_message
- platform: openthread_info
ip_address:
name: "Thread IP"
id: thread_ip
role:
name: "Thread Role"
id: thread_role
channel:
name: "Thread Channel"
id: thread_channel
rloc16:
name: "Thread RLOC16"
id: thread_rloc16
# -------------------------------------------------------------------
# Hardware SPI Pin Mapping (ESP32-H2)
@@ -139,12 +110,18 @@ font:
- file:
type: gfonts
family: Overpass
weight: 400
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 (V4 panel = 2.13inv3 in ESPHome)
# Waveshare 2.13-inch e-Paper Display (250x122 standard resolution)
# -------------------------------------------------------------------
display:
- platform: waveshare_epaper
@@ -157,35 +134,75 @@ display:
update_interval: 30min
id: my_display
lambda: |-
if (id(status_message).has_state()) {
// Header
it.print(5, 3, id(font_header), id(status_message).state.c_str());
it.line(0, 26, it.get_width(), 26);
// Mode Status
if (id(defrost_mode)) {
it.print(5, 32, id(font_header), "MODE: DEFROST");
} else {
it.print(5, 32, id(font_header), "MODE: NORMAL");
}
// Cloud Alert Status Box
if (id(cloud_alert)) {
it.filled_rectangle(5, 60, 240, 24, COLOR_OFF); // Inverse background if supported, or frame box
it.print(10, 62, id(font_body), "CLOUD ALERT ACTIVE");
} else {
it.print(10, 62, id(font_body), "System Normal");
}
// Footer: Thread Info
it.line(0, 95, it.get_width(), 95);
it.printf(5, 100, id(font_body), "Role: %s | Ch: %s",
id(thread_role).state.c_str(),
id(thread_channel).state.c_str());
} else {
// --- 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 Pi updates...");
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());
BIN
View File
Binary file not shown.
+37 -16
View File
@@ -2,6 +2,7 @@ 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')
@@ -10,12 +11,16 @@ image_CoolHiSmall_bits = bytearray(b'\x1f\xff\xc0?\xff\xe0\x7f\xff\xf0\xff\xdf\x
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
@@ -70,44 +75,54 @@ class MicrowaveScreen:
self.display.fill_rect(x, y, fill_width, height, color)
def update(self, cooking_state: CookingState | None, defrost_mode: bool = False):
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 cooking_state or cooking_state.state == CookingStates.IDLE: # Is not currently cooking
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.state == CookingStates.STIRRING_REQUIRED:
if cooking_state and cooking_state.state == CookingStates.STIRRING_REQUIRED:
self._message("Pls Stir", True)
elif cooking_state.state == CookingStates.ALERT:
self._message("Alert !", True)
elif cooking_state.state == CookingStates.DONE:
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)
else: # Progress bar
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.paused:
if cooking_state and cooking_state.paused:
self.h_centered_text("Paused", 40, 1)
elif cooking_state.state != CookingStates.DONE:
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.current_dish_temp is not None and cooking_state.current_dish_temp is not 0.0:
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.power_level is not None:
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:
@@ -118,12 +133,18 @@ class MicrowaveScreen:
self.show()
pass
def _message(self, text, alert):
self.display.fill_rect(0, 27, self.width, CHARACTER_HEIGHT + 4, 1)
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, 10, 29)
self.display.blit(fb_image_Alert_bits, self.width - 10, 29)
self.h_centered_text(text, 29, 0)
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()
+153 -53
View File
@@ -1,8 +1,6 @@
import gc
import sys
import time
import _thread
from lib.microwaveScreen import MicrowaveScreen
import uasyncio as asyncio
from machine import Pin, SoftI2C
import ssd1306
@@ -18,6 +16,9 @@ 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:
@@ -28,6 +29,8 @@ 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
@@ -46,10 +49,45 @@ PING_PAYLOAD = {
"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.message, True)
microwave_screen.message(alert.small_message if alert.small_message else alert.message, True)
def init_hardware():
"""Initializes all hardware components."""
@@ -84,10 +122,9 @@ def init_hardware():
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:
@@ -96,19 +133,22 @@ def lora_hardware_thread():
# 1. Send periodic heartbeat
if now - last_heartbeat_time >= config.LORA_HEARTBEAT_INTERVAL:
last_heartbeat_time = now
log("\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():
@@ -138,10 +178,23 @@ def cooking_state_temperature_provider():
# 3. Handle fresh incoming reading
if not temp_is_invalid(current_temp):
temps = [float(current_temp[0]), float(current_temp[1])]
last_temp = [temps[0], temps[1]] # Keep a safe reference copy
# Reset current_temp buffer to consume the value
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
@@ -152,38 +205,57 @@ def cooking_state_temperature_provider():
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})
microwave_screen.update(cooking_state, defrost_mode)
# 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):
# Update OLED display
if microwave_screen:
microwave_screen.update(state, defrost_mode)
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, temperature_asked
global cooking_state, cooking_start_time, temperature_asked, microwave_state
while True:
if uart_device and uart_device.any():
@@ -196,6 +268,9 @@ async def uart_polling_task():
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"],
@@ -208,6 +283,7 @@ async def uart_polling_task():
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
@@ -226,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, defrost_mode, microwave_screen
global cooking_state, defrost_mode, microwave_screen, microwave_state
while True:
while not data_queue.empty():
@@ -234,31 +310,57 @@ 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 data["action"] == LoraCommands.TOGGLE_DEFROST:
elif action == LoraCommands.TOGGLE_DEFROST:
print("[LoRa Process] Toggling defrost mode via orchestrator command.")
defrost_mode = data["defrost_state"]
microwave_screen.update(cooking_state, defrost_mode)
elif data["action"] == LoraCommands.NEW_ALERT:
alert = Alert(data.get("alert_type"), data.get("message", "Unsafe area"))
alert.timestamp = data.get("timestamp", time.time())
print(f"[LoRa Process] New alert received via orchestrator: {alert.to_dict()}")
alert_manager.add_alert(alert)
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)
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)
@@ -271,11 +373,17 @@ async def cooking_loop_task():
cooking_state.update_tick()
log_counter += 1
# Print log output every 5 seconds (10 ticks x 500ms)
# 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)
@@ -288,7 +396,7 @@ async def memory_cleanup_task():
# --- BOOTSTRAP ---
async def main():
global cooking_state, defrost_mode, microwave_screen, alert_manager
global alert_manager
print("[Main] Starting application...")
init_hardware()
@@ -296,15 +404,8 @@ async def main():
alert_manager = AlertManager()
alert_manager.set_on_alert_callback(on_new_alert)
# Launch dedicated hardware thread for LoRa RX
try:
_thread.stack_size(16 * 1024)
except Exception:
pass
_thread.start_new_thread(lora_hardware_thread, ())
print("[Main] LoRa hardware background thread started.")
# Launch background async tasks
# 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())
@@ -312,8 +413,7 @@ async def main():
print("[Main] All async tasks running concurrently!")
microwave_screen.update(None, defrost_mode)
update_screen()
# Keep main task alive indefinitely
while True:
await asyncio.sleep(3600)
+7
View File
@@ -3,3 +3,10 @@
- AP (dynamic) : `10.110.0.190`
- Generated Wifi : `192.168.50.1`
# RFID
## Cards
- 2700423026
- 270042302B
+2 -2
View File
@@ -3,13 +3,13 @@ services:
image: openthread/border-router:latest
container_name: otbr
restart: unless-stopped
privileged: true
# privileged: true
network_mode: host
cap_add:
- NET_ADMIN
environment:
- OT_RCP_DEVICE=spinel+hdlc+uart:///dev/ttyUSB1?uart-baudrate=460800
- OT_INFRA_IF=wlan0
- OT_INFRA_IF=wlan1
- OT_THREAD_IF=wpan0
- OT_LOG_LEVEL=6
- FIREWALL=0
+1
View File
@@ -0,0 +1 @@
zeJlYcaW0dji4A88Q3OGfvqgqXUuUAm0
+45 -11
View File
@@ -1,7 +1,7 @@
import asyncio
import time
import requests
import ipaddress
import datetime
from typing import Dict
from zeroconf import ServiceStateChange
from zeroconf.asyncio import AsyncZeroconf, AsyncServiceBrowser, AsyncServiceInfo
@@ -14,6 +14,7 @@ class DisplayManager:
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."""
@@ -48,27 +49,60 @@ class DisplayManager:
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)
async def broadcast_state(self, device_id: str, microwave_states: dict, button_state: bool, cloud_alert: bool):
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 = {
"device_id": device_id,
"timestamp": int(time.time()),
"cloud_alert": cloud_alert,
"defrost_mode": button_state,
"microwaves": [
{"id": mw_id, "state": state}
for mw_id, state in microwave_states.items()
]
"update_time": update_time_str,
"microwaves": microwaves_list
}
# Dispatch POST requests in parallel without blocking the main loop
@@ -88,9 +122,9 @@ class DisplayManager:
timeout=3.0
)
response.raise_for_status()
print(f"[DisplayManager] ✅ Updated {name}")
print(f"[DisplayManager] Updated {name}")
except Exception as e:
print(f"[DisplayManager] ⚠️ Error pushing state to {name}: {e}")
print(f"[DisplayManager] Error pushing state to {name}: {e}")
async def stop(self):
"""Clean up mDNS browser and Zeroconf instance."""
+17 -12
View File
@@ -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"
+533 -265
View File
@@ -3,19 +3,32 @@ import json
import time
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 shared.alerts import AlertManager, Alert, AlertType
from sensors import ultrasonicRanger, temp_hum, button, camera, buzzer
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 # <-- Import display manager
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."""
@@ -42,6 +55,12 @@ 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;"
@@ -58,24 +77,67 @@ 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
@@ -97,6 +159,13 @@ 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 Local] Paho background loop started.")
@@ -122,7 +191,7 @@ if hasattr(cloud_mqtt_client._client, "loop_start"):
print("[MQTT Cloud] Paho background loop started.")
# --- BACKGROUND TASKS (PRODUCERS) ---
# --- BACKGROUND TASKS ---
async def lora_listener_task():
"""Polls LoRa and pushes to the async queue."""
print("[LoRa] Async listener started.")
@@ -178,238 +247,80 @@ async def cloud_mqtt_listener_task():
})
await asyncio.sleep(0.1)
async def display_broadcast_task():
"""Monitors state changes and broadcasts updates to all connected screens."""
last_state = None
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:
current_state = (dict(microwave_states), button_state, cloud_alert)
if current_state != last_state:
last_state = current_state
await display_manager.broadcast_state(
device_id=DEVICE_ID,
microwave_states=microwave_states,
button_state=button_state,
cloud_alert=cloud_alert
)
await asyncio.sleep(3.0)
def button_callback():
"""Button physical interrupt callback."""
global button_state
if microwave_states.get("2") == MicrowaveState.COOKING or microwave_states.get("2") == MicrowaveState.DONE:
print("[Button] Toggling pause/resume for microwave '2'.")
lora.send_reliable({"id": DEVICE_ID, "microwave_id": "2", "action": LoraCommands.TOGGLE_PAUSE})
else:
button_state = not button_state
print(f"[Button] Defrost state toggled to: {button_state}")
lora.send_reliable({"id": DEVICE_ID, "microwave_id": "2", "action": LoraCommands.TOGGLE_DEFROST, "defrost_state": button_state})
button.set_callback(button_callback)
button.start_button_monitoring_thread()
# --- Alert manager ---
alert_manager = None
def on_new_alert(alert):
# Sends to cloud server
alert_dict = alert.to_dict()
alert_dict["orchestrator_id"] = DEVICE_ID
response = requests.post(
"https://smartwave.matthiasg.dev/alert",
json=alert_dict,
)
response.raise_for_status()
res_json = response.json()
print(f"[Alert Handling] Alert sent to cloud: {alert_dict}, Response: {res_json}")
# Send the alert to the lora device
lora.send_reliable(payloads.lora_new_alert(alert))
# TODO : envoi de l'alerte aux écrans externes
# --- HARDWARE CONTROLLERS ---
def _stop_hardware(microwave_id: str):
print(f"[{microwave_id}] /!\\ Emergency stop issued to hardware.")
# TODO: Add LoRa STOP command here
# --- ASYNC COOKING LOGIC ---
def read_local_sensors(microwave_id, initial_dish_height):
"""Blocking function to read local I2C/SPI sensors. Runs in a thread."""
print(f"[{microwave_id}] Reading local physical sensors...")
sensor_data = {
"microwave_id": microwave_id,
"defrost_mode": button_state,
"ultrasonic_distance": initial_dish_height # Reuse height from trigger
}
# Temp / Hum (handles DHT error safely)
try:
temp, hum = temp_hum.get_temperature_and_humidity_with_retry()
if temp is not None:
sensor_data["temperature"] = temp
sensor_data["humidity"] = hum
except Exception as e:
log(f"[{microwave_id}] DHT read warning: {e}")
# Camera
try:
sensor_data["camera_image"] = camera.get_picture()
except Exception as e:
log(f"[{microwave_id}] Camera read failed: {e}")
return sensor_data
async def handle_new_dish(microwave_id, detected_height):
"""Triggered when a new dish is placed inside."""
microwave_states[microwave_id] = MicrowaveState.ANALYZING
print(f"\n[{microwave_id}] 🍽️ Dish detected at {detected_height:.1f} cm! Requesting IR from microwave...")
# 1. Setup synchronization event and clear previous cache for this microwave
event = asyncio.Event()
ir_data_events[microwave_id] = event
ir_data_cache.pop(microwave_id, None)
# 2. Send IR request to ESP32 via MQTT immediately
mqtt_client.publish(
config.MQTT_TOPIC_COOKING,
payloads.mqtt_cooking_init(microwave_id),
qos=config.MQTT_QOS
)
# 3. Start local sensor reading in parallel
sensor_task = asyncio.create_task(asyncio.to_thread(read_local_sensors, microwave_id, detected_height))
# 4. Wait for local sensors to finish reading
sensors_data = await sensor_task
# Check if dish was removed while reading sensors
if microwave_states.get(microwave_id) != MicrowaveState.ANALYZING:
print(f"[{microwave_id}] Dish removed during sensor read. Aborting.")
ir_data_events.pop(microwave_id, None)
return
# 5. Wait for MQTT IR data
try:
await asyncio.wait_for(event.wait(), timeout=10.0)
ir_payload = ir_data_cache.get(microwave_id, {})
sensors_data["ir_initial_temp"] = ir_payload.get("dish_temp")
sensors_data["ir_ambient_temp"] = ir_payload.get("ambient_temp")
print(f"[{microwave_id}] IR data synchronized successfully: {ir_payload}")
# 6. Dispatch cloud request task
asyncio.create_task(request_cloud_cooking_plan(microwave_id, sensors_data))
except asyncio.TimeoutError:
print(f"[{microwave_id}] ⚠️ Timeout waiting for MQTT IR data from ESP32.")
sensors_data["ir_initial_temp"] = None
sensors_data["ir_ambient_temp"] = None
finally:
ir_data_events.pop(microwave_id, None)
async def request_cloud_cooking_plan(microwave_id, sensors_data):
"""Sends all data to the cloud with up to 3 retries (330s timeout for AI generation)."""
global cloud_alert, alert_manager
microwave_states[microwave_id] = MicrowaveState.WAITING_FOR_CLOUD
URL = "https://smartwave.matthiasg.dev/cooking-params"
# Format image
if isinstance(sensors_data.get("camera_image"), bytes):
sensors_data["camera_image"] = base64.b64encode(sensors_data["camera_image"]).decode("utf-8")
print(f"[{microwave_id}] Requesting cooking plan from cloud app...")
max_retries = 3
retry_delay_seconds = 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:
print(f"[{microwave_id}] Dish removed or state changed. Aborting API request.")
return
try:
print(f"[{microwave_id}] Connection attempt {attempt}/{max_retries} (Waiting up to 5.5 min)...")
# Quick non-blocking read via pigpio IPC
tag_id = await asyncio.to_thread(rfid.read_tag)
current_time = time.time()
response = await asyncio.to_thread(
requests.post,
URL,
json=sensors_data,
timeout=HTTP_TIMEOUT
)
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
# Abort if state changed while waiting for cloud AI response
if microwave_states.get(microwave_id) != MicrowaveState.WAITING_FOR_CLOUD:
print(f"[{microwave_id}] Dish removed during API request. Discarding API plan.")
return
last_scanned_tag = tag_id
last_scan_time = current_time
print(f"[RFID] Tag detected: {tag_id}. Verifying with API...")
response.raise_for_status()
is_valid = await check_rfid_card_api(tag_id)
# 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] = 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'])
alert_manager.add_alert(alert)
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:
alert = Alert(AlertType.ERROR, f"Cloud API error for microwave {microwave_id}: {res_json['error']}")
return
plan = res_json.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: {res_json}")
microwave_states[microwave_id] = None
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 = 20 # Set to 20s for debug
c_temp = 50 # Set to 50°C for debug
mqtt_client.publish(
config.MQTT_TOPIC_COOKING,
payloads.mqtt_cooking_config(microwave_id, c_time, c_power, c_temp),
qos=config.MQTT_QOS
)
return
print(f"[RFID] Card {tag_id} REJECTED / Invalid.")
# Optional: Sound error feedback beep
buzzer.buzzer_siren(beats_nb=4)
except Exception as e:
# Print stacktrace
import traceback
traceback.print_exc()
print(f"[{microwave_id}] Cloud API Error (Attempt {attempt}/{max_retries}): {e}")
print(f"[RFID Task] Error reading RFID tag: {e}")
if attempt < max_retries:
print(f"[{microwave_id}] Retrying in {retry_delay_seconds} seconds...")
# 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:
print(f"[{microwave_id}] State changed during retry wait. Aborting retries.")
return
await asyncio.sleep(1)
# Sleep 100ms between checks to keep CPU usage near zero
await asyncio.sleep(0.2)
# Executed only if all 3 retries failed
print(f"[{microwave_id}] All cloud retries failed. Setting global alert flag.")
cloud_alert = True
microwave_states[microwave_id] = MicrowaveState.DONE
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
)
# --- MAIN LOGIC TASKS ---
async def process_messages_task():
"""Consumes the unified event queue."""
# Helper to normalize config topics to str
@@ -422,18 +333,26 @@ async def process_messages_task():
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 in (CookingStates.DONE, CookingStates.STIRRING_REQUIRED, CookingStates.ALERT):
beats = 5 if n_state == CookingStates.ALERT else (1 if n_state == CookingStates.STIRRING_REQUIRED else 3)
buzzer.buzzer_siren(beatsNb=beats)
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.")
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"]
@@ -450,6 +369,14 @@ async def process_messages_task():
# 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),
@@ -475,13 +402,331 @@ async def process_messages_task():
endpoint = data.get("endpoint")
if endpoint:
# Schedule as a background task so processing the queue isn't stalled
asyncio.create_task(handle_telemetry_request(endpoint))
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}")
async def handle_telemetry_request(endpoint: str):
# === 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").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}, 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.")
# TODO: Add LoRa STOP command here
# --- ASYNC COOKING LOGIC ---
def read_local_sensors(microwave_id, initial_dish_height):
"""Blocking function to read local I2C/SPI sensors. Runs in a thread."""
print(f"[{microwave_id}] Reading local physical sensors...")
sensor_data = {
"microwave_id": microwave_id,
"defrost_mode": button_state,
"ultrasonic_distance": initial_dish_height # Reuse height from trigger
}
# Temp / Hum (handles DHT error safely)
try:
temp, hum = temp_hum.get_temperature_and_humidity_with_retry()
if temp is not None:
sensor_data["temperature"] = temp
sensor_data["humidity"] = hum
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}")
# Camera
try:
sensor_data["camera_image"] = camera.get_picture()
except Exception as e:
log(f"[{microwave_id}] Camera read failed: {e}")
return sensor_data
async def handle_new_dish(microwave_id, detected_height):
"""Triggered when a new dish is placed inside."""
microwave_states[microwave_id].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
event = asyncio.Event()
ir_data_events[microwave_id] = event
ir_data_cache.pop(microwave_id, None)
# 2. Send IR request to ESP32 via MQTT immediately
mqtt_client.publish(
config.MQTT_TOPIC_COOKING,
payloads.mqtt_cooking_init(microwave_id),
qos=config.MQTT_QOS
)
# 3. Start local sensor reading in parallel
sensor_task = asyncio.create_task(asyncio.to_thread(read_local_sensors, microwave_id, detected_height))
# 4. Wait for local sensors to finish reading
sensors_data = await sensor_task
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).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
try:
await asyncio.wait_for(event.wait(), timeout=10.0)
ir_payload = ir_data_cache.get(microwave_id, {})
sensors_data["ir_initial_temp"] = ir_payload.get("dish_temp")
sensors_data["ir_ambient_temp"] = ir_payload.get("ambient_temp")
print(f"[{microwave_id}] IR data synchronized successfully: {ir_payload}")
# 6. Dispatch cloud request task
asyncio.create_task(request_cloud_cooking_plan(microwave_id, sensors_data))
except asyncio.TimeoutError:
print(f"[{microwave_id}] Timeout waiting for MQTT IR data from ESP32.")
sensors_data["ir_initial_temp"] = None
sensors_data["ir_ambient_temp"] = None
finally:
ir_data_events.pop(microwave_id, None)
# === 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 (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
if isinstance(sensors_data.get("camera_image"), bytes):
sensors_data["camera_image"] = base64.b64encode(sensors_data["camera_image"]).decode("utf-8")
print(f"[{microwave_id}] Requesting cooking plan from cloud app...")
max_retries = 3
retry_delay_seconds = 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).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} (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()
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: {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")
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 state changes mid-wait
for _ in range(retry_delay_seconds):
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 cloud retries failed. Setting global alert flag.")
set_cloud_alert(True)
microwave_states[microwave_id].set_state(MicrowaveState.DONE)
# --- MAIN LOGIC TASKS ---
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.
@@ -494,10 +739,13 @@ async def handle_telemetry_request(endpoint: str):
# 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,
microwave_states=microwave_states_snapshot,
button_state=button_state,
cloud_alert=cloud_alert,
gps_data=gps.get_gps_data(),
@@ -508,9 +756,10 @@ async def handle_telemetry_request(endpoint: str):
)
# 4. Wait for he microwave turn to send the telemetry data to the cloud endpoint
timeout = int(DEVICE_ID.split("_")[-1]) * config.TELEMETRY_SEND_INTERVAL
print(f"[Telemetry] Waiting for {timeout}s before sending telemetry to avoid collisions...")
await asyncio.sleep(timeout)
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...")
@@ -518,15 +767,19 @@ async def handle_telemetry_request(endpoint: str):
requests.post,
endpoint,
data=telemetry_payload, # <-- Changed from json=telemetry_payload
headers={"Content-Type": "application/json"},
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}")
@@ -556,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
@@ -582,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)
@@ -591,11 +847,11 @@ 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
global async_event_queue, display_update_queue
print("Orchestrateur prêt. Lancement des tâches...")
# Initialize the alert manager and set the callback
@@ -609,7 +865,10 @@ async def main():
# 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(),
@@ -617,7 +876,15 @@ async def main():
cloud_mqtt_listener_task(),
process_messages_task(),
monitor_dish_height_task(),
display_broadcast_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__":
@@ -626,6 +893,7 @@ 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()
+10 -1
View File
@@ -1,7 +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
zeroconf>=0.131.0
# sudo apt install -y swig python3-dev liblgpio-dev
# ... Replaces this ?
# sudo apt install pigpio python3-pigpio
# sudo systemctl enable pigpiod --now
+138
View File
@@ -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}")
+2
View File
@@ -6,3 +6,5 @@ import sensors.button as button
import sensors.gps as gps
import sensors.camera as camera
import sensors.buzzer as buzzer
import sensors.rgb_lcd as rgb_lcd
import sensors.rfid_reader as rfid_reader
+13 -19
View File
@@ -1,7 +1,7 @@
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
@@ -10,28 +10,25 @@ 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:
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
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():
last_stable_state = 0
candidate_state = 0
consecutive_count = 0
# Require 3 consecutive identical reads (~60ms) to confirm a valid state change
REQUIRED_CONSECUTIVE_READS = 3
# Minimum time gap (in seconds) between allowed button triggers (cooldown)
DEBOUNCE_COOLDOWN = 0.3
last_trigger_time = 0
@@ -45,11 +42,9 @@ def monitor_button():
candidate_state = current_state
consecutive_count = 1
# State is confirmed stable across multiple reads
if consecutive_count >= REQUIRED_CONSECUTIVE_READS:
now = time.time()
# Rising edge detection (0 -> 1 transition) with cooldown timer
if candidate_state == 1 and last_stable_state == 0:
if (now - last_trigger_time) > DEBOUNCE_COOLDOWN:
last_trigger_time = now
@@ -63,7 +58,6 @@ def monitor_button():
time.sleep(0.02) # 20ms poll interval
else:
# Lock was busy or read failed; reset candidate counter to reject noisy spikes
consecutive_count = 0
time.sleep(0.01)
+43 -22
View File
@@ -1,33 +1,54 @@
import time
import threading
import grovepi
from sensors.lock import grove_lock
from sensors.lock import safe_grove_access
from shared import config
# Pin definition
BUZZER_PIN = 8
BUZZER_PIN = 8 # Must be a PWM pin on GrovePi (D3, D5, D6, D8 support analogWrite)
grovepi.pinMode(BUZZER_PIN, "OUTPUT")
buzzer_state = 0
# 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 set_buzzer(state: int):
global buzzer_state
buzzer_state = state
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:
with grove_lock:
grovepi.digitalWrite(BUZZER_PIN, state)
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 occurred while setting buzzer state: {e}")
print(f"[BUZZER] Error during siren execution: {e}")
finally:
_stop_sound()
def buzzer_toggle():
global buzzer_state
new_state = 1 - buzzer_state
set_buzzer(new_state)
def buzzer_siren(beatsNb: int = 3, delay: float = 0.3):
for _ in range(beatsNb):
set_buzzer(1)
time.sleep(delay)
set_buzzer(0)
time.sleep(delay)
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)
+17 -4
View File
@@ -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()
+14 -13
View File
@@ -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
+26
View File
@@ -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()
@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()
+101
View File
@@ -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()
+104
View File
@@ -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
+20 -9
View File
@@ -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
+11 -20
View File
@@ -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
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"Error: {e}")
return None
finally:
grove_lock.release()
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
+1
View File
@@ -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
+159
View File
@@ -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()
+3 -2
View File
@@ -7,13 +7,14 @@ 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
+24 -3
View File
@@ -1,4 +1,10 @@
import time
try:
import asyncio
import inspect
ASYNCIO = True
except ImportError:
ASYNCIO = False
class AlertManager:
def __init__(self):
@@ -8,10 +14,22 @@ class AlertManager:
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)
if self.on_alert_callback:
self.on_alert_callback(alert)
self.call_on_alert_callback(alert)
def get_alerts(self):
return self.alerts
@@ -20,10 +38,12 @@ class AlertManager:
self.alerts.clear()
class Alert:
def __init__(self, alertType: "AlertType", message: str):
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 {
@@ -45,3 +65,4 @@ class Alert:
class AlertType:
COOKING_SAFETY = "COOKING_SAFETY"
TEMPERATURE_ALERT = "TEMPERATURE_ALERT"
+14 -3
View File
@@ -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
@@ -16,9 +26,10 @@ CLOUD_MQTT_QOS = 2
MQTT_HELLO_INTERVAL = 30
# Microwave Model
COOKING_COMPARTMENT_HEIGHT = 30 # cm
BUZZER_ACTIVATED = False
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
+2 -2
View File
@@ -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}")
+108 -87
View File
@@ -28,6 +28,12 @@ class BaseLoraDevice:
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)
@@ -75,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)
msg_id = self._generate_msg_id()
payload["_msg_id"] = msg_id
if isinstance(payload, dict):
payload = dict(payload)
else:
payload = {"data": payload}
log(f"\n[ReliableLoRa] === Starting send_reliable for msg_id {msg_id} ===")
msg_id = self._generate_msg_id()
payload["_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)
log(f"\n[ReliableLoRa] === Starting send_reliable for msg_id {msg_id} ===")
# 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)
for attempt in range(max_retries):
log(f"[ReliableLoRa] Attempt {attempt + 1}/{max_retries} transmitting msg_id {msg_id}")
self.send(payload)
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."""
@@ -296,19 +300,28 @@ 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, timeout=1.5):
"""Sends AT command and reads response until line received or timeout."""
"""Sends AT command and safely handles async incoming packets."""
self.ser.write(f"{cmd}\r\n".encode('utf-8'))
start = time.time()
resp = ""
while (time.time() - start) < timeout:
if self.ser.in_waiting > 0:
line = self.ser.readline().decode('utf-8', errors='ignore')
resp += line
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)
@@ -364,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 = 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 = 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}
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
@@ -438,7 +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"
COOKING_UPDATE = "cooking_update"
TOGGLE_PAUSE = "toggle_pause"
TOGGLE_DEFROST = "toggle_defrost"
NEW_ALERT = "new_alert"
+95
View File
@@ -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
+23 -6
View File
@@ -2,10 +2,6 @@ from time import time
from shared.deviceTypes import DEVICE_TYPES
from shared.alerts import Alert, AlertType
import shared.config as config
try:
from shared.lora_device import LoraCommands
except ImportError:
pass # No need
try:
@@ -73,8 +69,29 @@ def telemetry_payload(device_id, microwave_states, button_state, cloud_alert, gp
"logs": logs
})
def lora_new_alert(alert: Alert):
return {
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
}