Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc30f6ce6c | |||
| 16e3325f8f | |||
| 988cb7958c | |||
| 053964e56c | |||
| ec9be8ac30 | |||
| f30b29be69 | |||
| 46acbb88c2 | |||
| 6ab2a2bea3 | |||
| 35a330e7ae |
@@ -0,0 +1,132 @@
|
||||
import time
|
||||
from shared.cookingState import CookingState, CookingStates
|
||||
from shared import config
|
||||
import framebuf
|
||||
|
||||
# Icon and bitmaps
|
||||
image_weather_frost_bits = bytearray(b'\x01\x00\x13\x901\x18s\x9c\x09 \x05@S\x94\xfe\xfeS\x94\x05@\x09 s\x9c1\x18\x13\x90\x01\x00\x00\x00')
|
||||
image_Alert_bits = bytearray(b'\xf7\x80\xe3\x80\xeb\x80\xc9\x80\xc9\x80\x80\x80\x88\x80\x00\x00')
|
||||
image_CoolHiSmall_bits = bytearray(b'\x1f\xff\xc0?\xff\xe0\x7f\xff\xf0\xff\xdf\xf8\xffW\xf8\xfd\x8d\xf8\xf9\xdc\xf8\xfe\xdb\xf8\xf7Wx\xfb\x8e\xf8\xe0\x008\xfb\x8e\xf8\xf7Wx\xfe\xdb\xf8\xf9\xdc\xf8\xfd\x8d\xf8\xffW\xf8\xff\xdf\xf8\x7f\xff\xf0?\xff\xe0\x1f\xff\xc0')
|
||||
image_CoolLoSmall_bits = bytearray(b'\x1f\xff\xc0 \x00 @\x00\x10\x80 \x08\x80\xa8\x08\x82r\x08\x86#\x08\x81$\x08\x88\xa8\x88\x84q\x08\x9f\xff\xc8\x84q\x08\x88\xa8\x88\x81$\x08\x86#\x08\x82r\x08\x80\xa8\x08\x80 \x08@\x00\x10 \x00 \x1f\xff\xc0')
|
||||
image_music_sound_wave_bits = bytearray(b'\x00\x00\x00\x02\x00\x00\x02 \x00\x02 \x00\x02 \x00\x0a(\x00\x0a\xa8\x00*\xa8\x00\xaa\xaa\x80\x0a\xaa\x00\x0a\xa8\x00\x0a \x00\x02 \x00\x02 \x00\x02\x00\x00\x00\x00\x00')
|
||||
image_weather_temperature_bits = bytearray(b'\x1c\x00\x22\x00+\x00*\x00+\x00*\x00+\x00*\x00*\x00I\x00\x9c\x80\xae\x80\xbe\x80\x9c\x80A\x00>\x00')
|
||||
fb_image_weather_frost_bits = framebuf.FrameBuffer(image_weather_frost_bits, 15, 16, framebuf.MONO_HLSB)
|
||||
fb_image_CoolHiSmall_bits = framebuf.FrameBuffer(image_CoolHiSmall_bits, 21, 21, framebuf.MONO_HLSB)
|
||||
fb_image_weather_temperature_bits = framebuf.FrameBuffer(image_weather_temperature_bits, 9, 16, framebuf.MONO_HLSB)
|
||||
fb_image_Alert_bits = framebuf.FrameBuffer(image_Alert_bits, 9, 8, framebuf.MONO_HLSB)
|
||||
fb_image_music_sound_wave_bits = framebuf.FrameBuffer(image_music_sound_wave_bits, 17, 16, framebuf.MONO_HLSB)
|
||||
fb_image_CoolLoSmall_bits = framebuf.FrameBuffer(image_CoolLoSmall_bits, 21, 21, framebuf.MONO_HLSB)
|
||||
|
||||
# Constants
|
||||
CHARACTER_WIDTH = 6
|
||||
CHARACTER_GAP = 2
|
||||
CHARACTER_FULL_WIDTH = CHARACTER_WIDTH + CHARACTER_GAP
|
||||
CHARACTER_HEIGHT = 7
|
||||
|
||||
|
||||
class MicrowaveScreen:
|
||||
# Used for animation
|
||||
# lastUpdateTime = 0 # Not used right now
|
||||
|
||||
def __init__(self, display):
|
||||
self.display = display
|
||||
self.width = display.width
|
||||
self.height = display.height
|
||||
|
||||
def h_centered_text(self, text, y, color=1):
|
||||
"""Centers text horizontally
|
||||
|
||||
Args:
|
||||
text (str): _description_
|
||||
y (int): _description_
|
||||
color (int, optional): _description_. Defaults to 1.
|
||||
"""
|
||||
text = str(text)
|
||||
text_width = len(text) * CHARACTER_FULL_WIDTH
|
||||
|
||||
if config.DEBUG:
|
||||
if text_width > self.width:
|
||||
print(f"[MicrowaveScreen] Warning: Text '{text}' is too long to fit on the screen.")
|
||||
|
||||
x = (self.width - text_width) // 2
|
||||
self.display.text(text, x, y, color)
|
||||
|
||||
def flick(self):
|
||||
self.display.fill(0)
|
||||
|
||||
def show(self):
|
||||
self.display.show()
|
||||
|
||||
def bootScreen(self):
|
||||
self.display.text("SmartWave", 28, 28, 1)
|
||||
self.show()
|
||||
|
||||
def _progressBar(self, progress, x, y, width=87, height=13, color=1):
|
||||
# Box outline
|
||||
self.display.rect(x, y, width, height, color)
|
||||
|
||||
# Fill the progress bar based on the progress value (0.0 to 1.0)
|
||||
fill_width = int(progress * (width)) # Subtract 2
|
||||
|
||||
self.display.fill_rect(x, y, fill_width, height, color)
|
||||
|
||||
def update(self, cooking_state: CookingState | None, defrost_mode: bool = False):
|
||||
self.flick()
|
||||
|
||||
# self.lastUpdateTime = time.time() # Not Used right now
|
||||
|
||||
if not cooking_state or cooking_state.state == CookingStates.IDLE: # Is not currently cooking
|
||||
self.h_centered_text("Ready !", 28, 1)
|
||||
else:
|
||||
# Screen center
|
||||
if cooking_state.state == CookingStates.STIRRING_REQUIRED:
|
||||
self._message("Pls Stir", True)
|
||||
elif cooking_state.state == CookingStates.ALERT:
|
||||
self._message("Alert !", True)
|
||||
elif cooking_state.state == CookingStates.DONE:
|
||||
self._message("Done !", False)
|
||||
else: # Progress bar
|
||||
elapsed_time = cooking_state.get_elapsed_time()
|
||||
estimated_progress = elapsed_time / (elapsed_time + cooking_state.estimated_remaining_time)
|
||||
self._progressBar(estimated_progress, 21, 25)
|
||||
|
||||
# Estimated remaining time
|
||||
if cooking_state.paused:
|
||||
self.h_centered_text("Paused", 40, 1)
|
||||
elif cooking_state.state != CookingStates.DONE:
|
||||
remaining_time = int(cooking_state.estimated_remaining_time)
|
||||
minutes = remaining_time // 60
|
||||
seconds = remaining_time % 60
|
||||
self.h_centered_text(f"{minutes:02}:{seconds:02}", 40, 1)
|
||||
|
||||
# Current dish temperature
|
||||
if cooking_state.current_dish_temp is not None and cooking_state.current_dish_temp is not 0.0:
|
||||
self.display.blit(fb_image_weather_temperature_bits, 27, 2)
|
||||
self.display.text(str(int(cooking_state.current_dish_temp)), 37, 6, 1)
|
||||
self.display.ellipse(54, 6, 1, 1, 1, False)
|
||||
self.display.text("C", 56, 6, 1)
|
||||
|
||||
# Power level
|
||||
if cooking_state.power_level is not None:
|
||||
self.display.blit(fb_image_music_sound_wave_bits, 74, 2)
|
||||
self.display.text(f"{cooking_state.power_level}W", 92, 6, 1)
|
||||
if defrost_mode:
|
||||
self.display.blit(fb_image_CoolHiSmall_bits, 2, 2)
|
||||
else:
|
||||
self.display.blit(fb_image_CoolLoSmall_bits, 2, 2)
|
||||
|
||||
self.show()
|
||||
pass
|
||||
|
||||
def _message(self, text, alert):
|
||||
self.display.fill_rect(0, 27, self.width, CHARACTER_HEIGHT + 4, 1)
|
||||
if alert:
|
||||
self.display.blit(fb_image_Alert_bits, 10, 29)
|
||||
self.display.blit(fb_image_Alert_bits, self.width - 10, 29)
|
||||
self.h_centered_text(text, 29, 0)
|
||||
|
||||
def message(self, text, alert):
|
||||
self.flick()
|
||||
self._message(text, alert)
|
||||
self.show()
|
||||
pass
|
||||
@@ -2,10 +2,11 @@ import gc
|
||||
import sys
|
||||
import time
|
||||
import _thread
|
||||
from lib.microwaveScreen import MicrowaveScreen
|
||||
import uasyncio as asyncio
|
||||
from machine import Pin, SoftI2C
|
||||
import framebuf
|
||||
import ssd1306
|
||||
import ujson
|
||||
|
||||
# Clean memory immediately
|
||||
gc.collect()
|
||||
@@ -30,7 +31,13 @@ data_queue = SafeQueue()
|
||||
lora = None
|
||||
uart_device = None
|
||||
magnetron_led = None
|
||||
display = None
|
||||
microwave_screen = None
|
||||
defrost_mode = False
|
||||
current_temp = [None, None]
|
||||
last_temp = [None, None]
|
||||
temperature_asked = False
|
||||
last_temp_request_time = 0
|
||||
|
||||
|
||||
PING_PAYLOAD = {
|
||||
"id": DEVICE_ID,
|
||||
@@ -39,7 +46,7 @@ PING_PAYLOAD = {
|
||||
|
||||
def init_hardware():
|
||||
"""Initializes all hardware components."""
|
||||
global lora, uart_device, magnetron_led, display
|
||||
global lora, uart_device, magnetron_led, microwave_screen
|
||||
|
||||
print("[Main] Initializing hardware peripherals...")
|
||||
|
||||
@@ -48,6 +55,14 @@ def init_hardware():
|
||||
vext.value(0)
|
||||
time.sleep_ms(100)
|
||||
|
||||
# Init OLED Display
|
||||
scl_pin = Pin(18, Pin.OUT, pull=Pin.PULL_UP)
|
||||
sda_pin = Pin(17, Pin.OUT, pull=Pin.PULL_UP)
|
||||
display_i2c = SoftI2C(scl=scl_pin, sda=sda_pin, freq=100000)
|
||||
display = ssd1306.SSD1306_I2C(128, 64, display_i2c, addr=0x3C)
|
||||
microwave_screen = MicrowaveScreen(display)
|
||||
microwave_screen.bootScreen()
|
||||
|
||||
# Init LoRa
|
||||
lora = get_lora()
|
||||
lora.configure(freq=868.1, sf=7)
|
||||
@@ -57,14 +72,6 @@ def init_hardware():
|
||||
magnetron_led.color = RGBLED.WHITE_YELLOW
|
||||
magnetron_led.off()
|
||||
|
||||
# Init OLED Display
|
||||
scl_pin = Pin(18, Pin.OUT, pull=Pin.PULL_UP)
|
||||
sda_pin = Pin(17, Pin.OUT, pull=Pin.PULL_UP)
|
||||
display_i2c = SoftI2C(scl=scl_pin, sda=sda_pin, freq=100000)
|
||||
display = ssd1306.SSD1306_I2C(128, 64, display_i2c, addr=0x3C)
|
||||
display.text("Booting...", 1, 2, 1)
|
||||
display.show()
|
||||
|
||||
# Init UART
|
||||
uart_device = get_uart(uart_id=1, tx_pin=46, rx_pin=45)
|
||||
|
||||
@@ -82,7 +89,7 @@ def lora_hardware_thread():
|
||||
# 1. Send periodic heartbeat
|
||||
if now - last_heartbeat_time >= config.LORA_HEARTBEAT_INTERVAL:
|
||||
last_heartbeat_time = now
|
||||
print("\n[LoRa Thread] Sending Heartbeat...")
|
||||
log("\n[LoRa Thread] Sending Heartbeat...")
|
||||
if lora:
|
||||
lora.send(PING_PAYLOAD)
|
||||
|
||||
@@ -98,7 +105,44 @@ def lora_hardware_thread():
|
||||
|
||||
# --- COOKING STATE CALLBACKS ---
|
||||
def cooking_state_temperature_provider():
|
||||
return 22.0, 29.0 # TODO: Replace with real temperature reading
|
||||
global current_temp, last_temp, temperature_asked, last_temp_request_time, uart_device
|
||||
|
||||
def temp_is_invalid(temp):
|
||||
return (
|
||||
temp is None
|
||||
or not isinstance(temp, (list, tuple))
|
||||
or len(temp) < 2
|
||||
or temp[0] is None
|
||||
or temp[1] is None
|
||||
)
|
||||
|
||||
# 1. Check for UART request timeout (reset lock if 3 seconds pass without a response)
|
||||
now = time.time()
|
||||
if temperature_asked and (now - last_temp_request_time > 3):
|
||||
print("[CookingState] Temperature request timed out. Retrying UART request...")
|
||||
temperature_asked = False
|
||||
|
||||
# 2. Trigger new UART request if idle
|
||||
if not temperature_asked and uart_device:
|
||||
temperature_asked = True
|
||||
last_temp_request_time = now
|
||||
uart_device.send_as_command(UARTCommand(UARTCommandType.TEMPERATURE_REQUEST, {}))
|
||||
|
||||
# 3. Handle fresh incoming reading
|
||||
if not temp_is_invalid(current_temp):
|
||||
temps = [float(current_temp[0]), float(current_temp[1])]
|
||||
last_temp = [temps[0], temps[1]] # Keep a safe reference copy
|
||||
|
||||
# Reset current_temp buffer to consume the value
|
||||
current_temp = [None, None]
|
||||
return temps
|
||||
|
||||
# 4. Fallback: Use last valid reading
|
||||
if not temp_is_invalid(last_temp):
|
||||
return [float(last_temp[0]), float(last_temp[1])]
|
||||
|
||||
# 5. Default fallback if no data has ever arrived
|
||||
return (0.0, 0.0)
|
||||
|
||||
def cooking_state_on_state_change(state):
|
||||
print(f"[CookingState] State changed to: {state.state}")
|
||||
@@ -114,15 +158,12 @@ def cooking_state_on_state_change(state):
|
||||
if lora:
|
||||
lora.send_reliable({"id": DEVICE_ID, "new_cooking_state": state.state})
|
||||
|
||||
# Update OLED display
|
||||
if display:
|
||||
display.fill(0)
|
||||
display.text(cookingState.CookingStates.get_state_name(state.state), 1, 2, 1)
|
||||
display.show()
|
||||
microwave_screen.update(cooking_state, defrost_mode)
|
||||
|
||||
def cooking_state_on_refresh(state):
|
||||
# TODO: Show screen information
|
||||
pass
|
||||
# Update OLED display
|
||||
if microwave_screen:
|
||||
microwave_screen.update(state, defrost_mode)
|
||||
|
||||
def cooking_state_on_pause(state):
|
||||
# If the cooking is unpaused and was in STIRRING_REQUIRED or ALERT state, we set the state back to COOKING.
|
||||
@@ -135,17 +176,19 @@ def cooking_state_on_pause(state):
|
||||
|
||||
async def uart_polling_task():
|
||||
"""Polls UART for incoming messages from the WiFi board."""
|
||||
global cooking_state
|
||||
global cooking_state, temperature_asked
|
||||
|
||||
while True:
|
||||
if uart_device and uart_device.any():
|
||||
command = uart_device.read_as_command()
|
||||
if command:
|
||||
print(f"[UART Task] Received command from WiFi Board: {command.command_type}")
|
||||
log(f"[UART Task] Received command from WiFi Board: {command.command_type}")
|
||||
if command.command_type == UARTCommandType.COOKING_PARAMS:
|
||||
params = command.payload
|
||||
print(f"[UART Task] Cooking parameters received: {params}")
|
||||
|
||||
uart_device.send_as_command(UARTCommand(UARTCommandType.TEMPERATURE_REQUEST, {}))
|
||||
|
||||
cooking_state = cookingState.CookingState(
|
||||
cook_time=params["cook_time"],
|
||||
power_level=params["power_level"],
|
||||
@@ -156,8 +199,18 @@ async def uart_polling_task():
|
||||
cooking_state.set_refresh_callback(cooking_state_on_refresh)
|
||||
cooking_state.set_pause_callback(cooking_state_on_pause)
|
||||
|
||||
await asyncio.sleep_ms(20)
|
||||
await asyncio.sleep_ms(200)
|
||||
cooking_state_on_state_change(cooking_state)
|
||||
elif command.command_type == UARTCommandType.TEMPERATURE_RESPONSE:
|
||||
try:
|
||||
# Check if payload is already a dict or needs JSON decoding
|
||||
payload = ujson.loads(command.payload) if isinstance(command.payload, str) else command.payload
|
||||
current_temp[0] = payload.get("dish_temp", 0.0)
|
||||
current_temp[1] = payload.get("ambient_temp", 0.0)
|
||||
except Exception as e:
|
||||
print(f"[UART Task] Error parsing temperature payload: {e}")
|
||||
finally:
|
||||
temperature_asked = False
|
||||
else:
|
||||
print(f"[UART Task] Unknown command type received: {command.command_type}")
|
||||
|
||||
@@ -166,7 +219,7 @@ async def uart_polling_task():
|
||||
|
||||
async def lora_process_task():
|
||||
"""Consumes packets pushed to data_queue by the LoRa hardware thread."""
|
||||
global cooking_state
|
||||
global cooking_state, defrost_mode, microwave_screen
|
||||
|
||||
while True:
|
||||
while not data_queue.empty():
|
||||
@@ -190,18 +243,26 @@ async def lora_process_task():
|
||||
print("[LoRa Process] Cooking resumed via orchestrator command.")
|
||||
else:
|
||||
log("[LoRa Process] No active cooking state to toggle pause/resume.")
|
||||
elif data["action"] == LoraCommands.TOGGLE_DEFROST:
|
||||
print("[LoRa Process] Toggling defrost mode via orchestrator command.")
|
||||
defrost_mode = data["defrost_state"]
|
||||
microwave_screen.update(cooking_state, defrost_mode)
|
||||
|
||||
await asyncio.sleep_ms(50)
|
||||
|
||||
|
||||
async def cooking_loop_task():
|
||||
"""Ticks the cooking state and logs information periodically."""
|
||||
"""Ticks the cooking state and logs information periodically without flooding output."""
|
||||
log_counter = 0
|
||||
while True:
|
||||
if cooking_state is not None:
|
||||
cooking_state.update_tick()
|
||||
log_counter += 1
|
||||
|
||||
# Print log output every 5 seconds (10 ticks x 500ms)
|
||||
if log_counter % 10 == 0:
|
||||
print(f"[Cooking Task] State: {cooking_state.state}, Temp: {cooking_state.current_dish_temp}, "
|
||||
f"Paused: {cooking_state.paused}, Remaining: {cooking_state.get_remaining_time():.2f}s, "
|
||||
f"Est. Remaining: {cooking_state.get_remaining_time_estimation():.2f}s")
|
||||
f"Paused: {cooking_state.paused}, Remaining: {cooking_state.get_remaining_time():.2f}s")
|
||||
|
||||
await asyncio.sleep_ms(500)
|
||||
|
||||
@@ -215,6 +276,7 @@ async def memory_cleanup_task():
|
||||
|
||||
# --- BOOTSTRAP ---
|
||||
async def main():
|
||||
global cooking_state, defrost_mode, microwave_screen
|
||||
print("[Main] Starting application...")
|
||||
|
||||
init_hardware()
|
||||
@@ -235,6 +297,8 @@ async def main():
|
||||
|
||||
print("[Main] All async tasks running concurrently!")
|
||||
|
||||
microwave_screen.update(None, defrost_mode)
|
||||
|
||||
# Keep main task alive indefinitely
|
||||
while True:
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
@@ -195,14 +195,16 @@ async def uart_task():
|
||||
try:
|
||||
cmd = uart_device.read_as_command()
|
||||
|
||||
if cmd:
|
||||
if cmd and hasattr(cmd, "command_type"):
|
||||
print("[UART] Command received from LoRa board:", cmd.command_type, cmd.payload)
|
||||
if (
|
||||
hasattr(cmd, "command_type")
|
||||
and cmd.command_type == UARTCommandType.COOKING_STATE_UPDATE
|
||||
):
|
||||
if cmd.command_type == UARTCommandType.COOKING_STATE_UPDATE:
|
||||
if cooking_state:
|
||||
cooking_state.set_state(cmd.payload.get("state"))
|
||||
elif cmd.command_type == UARTCommandType.TEMPERATURE_REQUEST:
|
||||
uart_device.send_as_command(UARTCommand(UARTCommandType.TEMPERATURE_RESPONSE, payloads.lora_sensor_data(
|
||||
mlx_temperature_sensor.read_object_temp() if mlx_temperature_sensor else 0,
|
||||
mlx_temperature_sensor.read_ambient_temp() if mlx_temperature_sensor else 0
|
||||
)))
|
||||
except Exception as e:
|
||||
print("[UART Task] Error reading command:", e)
|
||||
|
||||
|
||||
@@ -127,12 +127,13 @@ async def mqtt_listener_task():
|
||||
def button_callback():
|
||||
"""Button physical interrupt callback."""
|
||||
global button_state
|
||||
if microwave_states.get("2") == MicrowaveState.COOKING:
|
||||
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()
|
||||
@@ -199,13 +200,15 @@ async def handle_new_dish(microwave_id, detected_height):
|
||||
ir_data_events.pop(microwave_id, None)
|
||||
return
|
||||
|
||||
# 5. Wait for MQTT IR data (if it already arrived, event.wait() returns instantly)
|
||||
# 5. Wait for MQTT IR data
|
||||
try:
|
||||
await asyncio.wait_for(event.wait(), timeout=10.0)
|
||||
ir_payload = ir_data_cache.get(microwave_id, {})
|
||||
sensors_data["ir_initial_temp"] = ir_payload.get("dish_temp")
|
||||
sensors_data["ir_ambient_temp"] = ir_payload.get("ambient_temp")
|
||||
print(f"[{microwave_id}] IR data synchronized successfully: {ir_payload}")
|
||||
# 6. Dispatch cloud request task
|
||||
asyncio.create_task(request_cloud_cooking_plan(microwave_id, sensors_data))
|
||||
except asyncio.TimeoutError:
|
||||
print(f"[{microwave_id}] ⚠️ Timeout waiting for MQTT IR data from ESP32.")
|
||||
sensors_data["ir_initial_temp"] = None
|
||||
@@ -213,9 +216,6 @@ async def handle_new_dish(microwave_id, detected_height):
|
||||
finally:
|
||||
ir_data_events.pop(microwave_id, None)
|
||||
|
||||
# 6. Dispatch cloud request task
|
||||
asyncio.create_task(request_cloud_cooking_plan(microwave_id, sensors_data))
|
||||
|
||||
async def request_cloud_cooking_plan(microwave_id, sensors_data):
|
||||
"""Sends all data to the cloud with up to 3 retries (30s interval)."""
|
||||
global cloud_alert
|
||||
@@ -259,8 +259,8 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
|
||||
cloud_alert = False # Reset alert flag on success
|
||||
microwave_states[microwave_id] = MicrowaveState.COOKING
|
||||
if config.DEBUG:
|
||||
c_time = min(c_time, 10) # Limit to 10s for debug
|
||||
c_temp = min(c_temp, 50) # Limit to 50°C for debug
|
||||
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),
|
||||
|
||||
+69
-27
@@ -3,6 +3,7 @@ import time
|
||||
|
||||
class CookingState:
|
||||
TEMPERATURE_TOLERANCE = 1.0
|
||||
MIN_SIGNIFICANT_HEATING_RATE = 0.05 # °C/s threshold to consider valid heating
|
||||
|
||||
def __init__(self, cook_time: int, power_level: int, target_temp: float, temperature_provider=None, on_state_change=None, on_refresh=None):
|
||||
self.cook_time = cook_time
|
||||
@@ -27,6 +28,9 @@ class CookingState:
|
||||
self._last_refresh_signature = None
|
||||
self._stirred = False
|
||||
|
||||
# Moving average filter for heating rate (°C / sec)
|
||||
self._smoothed_heating_rate = 0.0
|
||||
|
||||
def set_temperature_provider(self, temperature_provider):
|
||||
self.temperature_provider = temperature_provider
|
||||
|
||||
@@ -45,9 +49,9 @@ class CookingState:
|
||||
|
||||
self.paused = True
|
||||
self._pause_started_at = time.time()
|
||||
# self._notify_refresh(force=True)
|
||||
if self.on_pause:
|
||||
self.on_pause(self)
|
||||
|
||||
def unpause(self):
|
||||
if not self.paused:
|
||||
return
|
||||
@@ -56,15 +60,14 @@ class CookingState:
|
||||
if self._pause_started_at is not None:
|
||||
self._paused_duration += now - self._pause_started_at
|
||||
|
||||
# self._pause_started_at = None
|
||||
self.paused = False
|
||||
# self._notify_refresh(force=True)
|
||||
|
||||
def toggle_pause(self):
|
||||
if self.paused:
|
||||
self.unpause()
|
||||
else:
|
||||
self.pause()
|
||||
if self.on_pause:
|
||||
self.on_pause(self)
|
||||
|
||||
def set_state(self, state):
|
||||
@@ -90,20 +93,56 @@ class CookingState:
|
||||
|
||||
def get_remaining_time_estimation(self) -> float:
|
||||
elapsed_time = self.get_elapsed_time()
|
||||
|
||||
# 1. Base timer remaining based on standard cook time
|
||||
timer_remaining = max(0.0, float(self.cook_time) - elapsed_time)
|
||||
|
||||
if self.current_dish_temp is None:
|
||||
# If temperature is unavailable or already met target, rely on standard timer
|
||||
if self.current_dish_temp is None or self.current_dish_temp >= (self.target_temp - self.TEMPERATURE_TOLERANCE):
|
||||
return timer_remaining
|
||||
|
||||
if self.current_dish_temp >= self.target_temp:
|
||||
return timer_remaining
|
||||
# 2. Prevent hitting 00:00 before stirring trigger:
|
||||
# If we passed half cook_time and temp is far from target, extend expected base time to 1.25x cook_time
|
||||
temp_progress = max(0.0, self.current_dish_temp) / max(1.0, self.target_temp)
|
||||
if elapsed_time > (self.cook_time * 0.5) and temp_progress < 0.8:
|
||||
adjusted_cook_time = self.cook_time * 1.25
|
||||
timer_remaining = max(0.0, adjusted_cook_time - elapsed_time)
|
||||
|
||||
# 3. Estimate using heating rate
|
||||
heating_rate = self._estimate_heating_rate()
|
||||
if heating_rate <= 0:
|
||||
return timer_remaining
|
||||
temp_needed = self.target_temp - self.current_dish_temp
|
||||
|
||||
target_remaining = (self.target_temp - self.current_dish_temp) / heating_rate
|
||||
return max(timer_remaining, max(0.0, target_remaining))
|
||||
if heating_rate > 0.01:
|
||||
rate_based_remaining = temp_needed / heating_rate
|
||||
else:
|
||||
# If flat/slow, project remaining time based on remaining missing temperature fraction
|
||||
temp_ratio = max(0.1, temp_needed / self.target_temp)
|
||||
rate_based_remaining = max(timer_remaining, self.cook_time * temp_ratio * 1.25)
|
||||
|
||||
# 4. Strict dynamic cap: Never exceed maximum possible execution window (2.0x cook_time total)
|
||||
max_possible_remaining = max(0.0, (self.cook_time * 2.0) - elapsed_time)
|
||||
bounded_remaining = min(rate_based_remaining, max_possible_remaining)
|
||||
|
||||
# Return the larger of the adjusted timer or the bounded prediction
|
||||
return max(timer_remaining, bounded_remaining)
|
||||
|
||||
def _estimate_heating_rate(self) -> float:
|
||||
"""Calculates heating rate in °C/sec over time interval."""
|
||||
if self._last_temperature_sample is None or self.current_dish_temp is None:
|
||||
return 0.0
|
||||
|
||||
last_time, last_temp = self._last_temperature_sample
|
||||
now = time.time()
|
||||
delta_time = now - last_time
|
||||
|
||||
if delta_time < 0.8: # Skip micro-ticks
|
||||
return 0.0
|
||||
|
||||
delta_temp = self.current_dish_temp - last_temp
|
||||
if delta_temp <= 0:
|
||||
return 0.0
|
||||
|
||||
return delta_temp / delta_time
|
||||
|
||||
def _read_temperatures(self):
|
||||
if self.temperature_provider is None:
|
||||
@@ -118,27 +157,29 @@ class CookingState:
|
||||
|
||||
raise ValueError("temperature_provider must return a pair: (dish_temp, ambient_temp)")
|
||||
|
||||
def _estimate_heating_rate(self):
|
||||
def _update_heating_rate(self):
|
||||
"""Calculates instantaneous rate and updates the Exponential Moving Average."""
|
||||
now = time.time()
|
||||
if self._last_temperature_sample is None:
|
||||
return 0.0
|
||||
self._last_temperature_sample = (now, self.current_dish_temp)
|
||||
return
|
||||
|
||||
last_time, last_temp = self._last_temperature_sample
|
||||
now = time.time()
|
||||
current_temp = self.current_dish_temp
|
||||
|
||||
if current_temp is None:
|
||||
return 0.0
|
||||
|
||||
delta_time = now - last_time
|
||||
if delta_time <= 0:
|
||||
return 0.0
|
||||
|
||||
return (current_temp - last_temp) / delta_time
|
||||
if delta_time <= 0.5 or self.current_dish_temp is None:
|
||||
return
|
||||
|
||||
instant_rate = (self.current_dish_temp - last_temp) / delta_time
|
||||
self._last_temperature_sample = (now, self.current_dish_temp)
|
||||
|
||||
# Exponential Moving Average (EMA) - alpha smooths out sensor noise
|
||||
alpha = 0.2
|
||||
self._smoothed_heating_rate = (alpha * instant_rate) + ((1.0 - alpha) * self._smoothed_heating_rate)
|
||||
|
||||
def _notify_state_change(self):
|
||||
if self.on_state_change is None:
|
||||
return
|
||||
|
||||
self.on_state_change(self)
|
||||
|
||||
def _notify_refresh(self, force=False):
|
||||
@@ -177,21 +218,22 @@ class CookingState:
|
||||
|
||||
now = time.time()
|
||||
elapsed_time = self.get_elapsed_time()
|
||||
self.estimated_remaining_time = self.get_remaining_time_estimation()
|
||||
|
||||
if self.current_dish_temp is not None:
|
||||
if elapsed_time < (self.cook_time / 2.0) and self.current_dish_temp >= self.target_temp and (self._paused_duration == None or self._paused_duration < 5): # If the dish is heating too fast, we require stirring
|
||||
self._update_heating_rate()
|
||||
|
||||
if elapsed_time < (self.cook_time / 2.0) and self.current_dish_temp >= self.target_temp and (self._paused_duration is None or self._paused_duration < 5):
|
||||
self.state = CookingStates.STIRRING_REQUIRED
|
||||
self.pause()
|
||||
elif elapsed_time >= self.cook_time and self.current_dish_temp >= (self.target_temp - self.TEMPERATURE_TOLERANCE):
|
||||
self.state = CookingStates.DONE
|
||||
elif elapsed_time >= self.cook_time * 1.25 and (self._paused_duration == None or self._paused_duration < 5): # If the dish is not heating up
|
||||
elif elapsed_time >= self.cook_time * 1.25 and (self._paused_duration is None or self._paused_duration < 5):
|
||||
self.state = CookingStates.STIRRING_REQUIRED
|
||||
self.pause()
|
||||
elif self._pause_started_at != None and (self._pause_started_at + self._paused_duration) < (now - (self.cook_time * 0.75)): # If the dish had to be pause and it's been a long time, we stop the cooking
|
||||
elif self._pause_started_at is not None and (self._pause_started_at + self._paused_duration) < (now - (self.cook_time * 0.75)):
|
||||
self.state = CookingStates.DONE
|
||||
|
||||
self._last_temperature_sample = (now, self.current_dish_temp)
|
||||
self.estimated_remaining_time = self.get_remaining_time_estimation()
|
||||
|
||||
if self.state != previous_state:
|
||||
self._notify_state_change()
|
||||
|
||||
@@ -33,10 +33,9 @@ class BaseLoraDevice:
|
||||
"""Sends an immediate acknowledgement packet back to the sender."""
|
||||
print(f"[ReliableLoRa] -> Triggering ACK send for msg_id: {ack_id}")
|
||||
if IS_MICROPYTHON:
|
||||
time.sleep_ms(100)
|
||||
time.sleep_ms(50)
|
||||
else:
|
||||
# Give LA66 chip time to finish logging RSSI and reset RF frontend
|
||||
time.sleep(0.25)
|
||||
time.sleep(0.05) # Reduced delay to fit ESP32 RX window
|
||||
|
||||
ack_payload = {"_type": "_ack", "_ack_id": ack_id}
|
||||
self.send(ack_payload)
|
||||
@@ -425,3 +424,4 @@ class LoraCommands:
|
||||
PING = "ping"
|
||||
COOKING_STATE_UPDATE = "cooking_state_update"
|
||||
TOGGLE_PAUSE = "toggle_pause"
|
||||
TOGGLE_DEFROST = "toggle_defrost"
|
||||
@@ -39,6 +39,12 @@ def mqtt_sensor_data(id_microwave, dish_temp, ambient_temp):
|
||||
"ambient_temp": ambient_temp
|
||||
})
|
||||
|
||||
def lora_sensor_data(dish_temp, ambient_temp):
|
||||
return as_json({
|
||||
"dish_temp": dish_temp,
|
||||
"ambient_temp": ambient_temp
|
||||
})
|
||||
|
||||
def mqtt_cooking_config(id_microwave, cook_time, power_level, target_temp):
|
||||
return as_json({
|
||||
"id_microwave": id_microwave,
|
||||
|
||||
@@ -75,6 +75,8 @@ class RGBLED:
|
||||
"""Sets the RGB color tuple (e.g., (255, 128, 0))."""
|
||||
if isinstance(rgb_tuple, (tuple, list)) and len(rgb_tuple) == 3:
|
||||
self._color = tuple(rgb_tuple)
|
||||
# Automatically turn LED on unless color is OFF (0,0,0)
|
||||
self._is_on = (self._color != self.OFF)
|
||||
self._apply()
|
||||
else:
|
||||
raise ValueError("Color must be a tuple of 3 integers: (R, G, B)")
|
||||
@@ -144,6 +146,7 @@ class RGBLED:
|
||||
if self._is_blinking:
|
||||
self._timer.deinit()
|
||||
self._is_blinking = False
|
||||
self.on() # Ensures LED remains ON steadily after blinking stops
|
||||
|
||||
def blink_toggle(self, interval_ms=500):
|
||||
"""Toggles blinking state (starts if stopped, stops if active)."""
|
||||
|
||||
@@ -98,3 +98,5 @@ class UARTCommandType:
|
||||
"""Enumeration of known UART command types."""
|
||||
COOKING_PARAMS = "COOKING_PARAMS"
|
||||
COOKING_STATE_UPDATE = "COOKING_STATE_UPDATE"
|
||||
TEMPERATURE_REQUEST = "TEMPERATURE_REQUEST"
|
||||
TEMPERATURE_RESPONSE = "TEMPERATURE_RESPONSE"
|
||||
Reference in New Issue
Block a user