Compare commits

...

9 Commits

Author SHA1 Message Date
Ninluc dc30f6ce6c Higher debug cooking params
Build, push image, and notify Watchtower / build-image (push) Successful in 57s
Build, push image, and notify Watchtower / notify (push) Successful in 11s
2026-08-07 16:51:08 +02:00
Ninluc 16e3325f8f Better estimated remaining time 2026-08-07 16:51:00 +02:00
Ninluc 988cb7958c Better cooking_state_temperature_provider 2026-08-07 16:50:14 +02:00
Ninluc 053964e56c UART commands for getting dish temperature 2026-08-07 16:14:43 +02:00
Ninluc ec9be8ac30 cooking state logs at interval 2026-08-07 16:13:47 +02:00
Ninluc f30b29be69 More Screen updates, icon and screens correction 2026-08-07 14:32:02 +02:00
Ninluc 46acbb88c2 Fix RGB lights 2026-08-06 19:42:34 +02:00
Ninluc 6ab2a2bea3 Fix Lora ? 2026-08-06 19:38:15 +02:00
Ninluc 35a330e7ae Microwave Screen + defrost mode 2026-08-06 19:27:35 +02:00
9 changed files with 328 additions and 77 deletions
+132
View File
@@ -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
+91 -27
View File
@@ -2,10 +2,11 @@ import gc
import sys import sys
import time import time
import _thread import _thread
from lib.microwaveScreen import MicrowaveScreen
import uasyncio as asyncio import uasyncio as asyncio
from machine import Pin, SoftI2C from machine import Pin, SoftI2C
import framebuf
import ssd1306 import ssd1306
import ujson
# Clean memory immediately # Clean memory immediately
gc.collect() gc.collect()
@@ -30,7 +31,13 @@ data_queue = SafeQueue()
lora = None lora = None
uart_device = None uart_device = None
magnetron_led = 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 = { PING_PAYLOAD = {
"id": DEVICE_ID, "id": DEVICE_ID,
@@ -39,7 +46,7 @@ PING_PAYLOAD = {
def init_hardware(): def init_hardware():
"""Initializes all hardware components.""" """Initializes all hardware components."""
global lora, uart_device, magnetron_led, display global lora, uart_device, magnetron_led, microwave_screen
print("[Main] Initializing hardware peripherals...") print("[Main] Initializing hardware peripherals...")
@@ -48,6 +55,14 @@ def init_hardware():
vext.value(0) vext.value(0)
time.sleep_ms(100) 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 # Init LoRa
lora = get_lora() lora = get_lora()
lora.configure(freq=868.1, sf=7) lora.configure(freq=868.1, sf=7)
@@ -57,14 +72,6 @@ def init_hardware():
magnetron_led.color = RGBLED.WHITE_YELLOW magnetron_led.color = RGBLED.WHITE_YELLOW
magnetron_led.off() 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 # Init UART
uart_device = get_uart(uart_id=1, tx_pin=46, rx_pin=45) 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 # 1. Send periodic heartbeat
if now - last_heartbeat_time >= config.LORA_HEARTBEAT_INTERVAL: if now - last_heartbeat_time >= config.LORA_HEARTBEAT_INTERVAL:
last_heartbeat_time = now last_heartbeat_time = now
print("\n[LoRa Thread] Sending Heartbeat...") log("\n[LoRa Thread] Sending Heartbeat...")
if lora: if lora:
lora.send(PING_PAYLOAD) lora.send(PING_PAYLOAD)
@@ -98,7 +105,44 @@ def lora_hardware_thread():
# --- COOKING STATE CALLBACKS --- # --- COOKING STATE CALLBACKS ---
def cooking_state_temperature_provider(): 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): def cooking_state_on_state_change(state):
print(f"[CookingState] State changed to: {state.state}") print(f"[CookingState] State changed to: {state.state}")
@@ -114,15 +158,12 @@ def cooking_state_on_state_change(state):
if lora: if lora:
lora.send_reliable({"id": DEVICE_ID, "new_cooking_state": state.state}) lora.send_reliable({"id": DEVICE_ID, "new_cooking_state": state.state})
# Update OLED display microwave_screen.update(cooking_state, defrost_mode)
if display:
display.fill(0)
display.text(cookingState.CookingStates.get_state_name(state.state), 1, 2, 1)
display.show()
def cooking_state_on_refresh(state): def cooking_state_on_refresh(state):
# TODO: Show screen information # Update OLED display
pass if microwave_screen:
microwave_screen.update(state, defrost_mode)
def cooking_state_on_pause(state): 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 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(): async def uart_polling_task():
"""Polls UART for incoming messages from the WiFi board.""" """Polls UART for incoming messages from the WiFi board."""
global cooking_state global cooking_state, temperature_asked
while True: while True:
if uart_device and uart_device.any(): if uart_device and uart_device.any():
command = uart_device.read_as_command() command = uart_device.read_as_command()
if 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: if command.command_type == UARTCommandType.COOKING_PARAMS:
params = command.payload params = command.payload
print(f"[UART Task] Cooking parameters received: {params}") print(f"[UART Task] Cooking parameters received: {params}")
uart_device.send_as_command(UARTCommand(UARTCommandType.TEMPERATURE_REQUEST, {}))
cooking_state = cookingState.CookingState( cooking_state = cookingState.CookingState(
cook_time=params["cook_time"], cook_time=params["cook_time"],
power_level=params["power_level"], 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_refresh_callback(cooking_state_on_refresh)
cooking_state.set_pause_callback(cooking_state_on_pause) 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) 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: else:
print(f"[UART Task] Unknown command type received: {command.command_type}") 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(): async def lora_process_task():
"""Consumes packets pushed to data_queue by the LoRa hardware thread.""" """Consumes packets pushed to data_queue by the LoRa hardware thread."""
global cooking_state global cooking_state, defrost_mode, microwave_screen
while True: while True:
while not data_queue.empty(): while not data_queue.empty():
@@ -190,18 +243,26 @@ async def lora_process_task():
print("[LoRa Process] Cooking resumed via orchestrator command.") print("[LoRa Process] Cooking resumed via orchestrator command.")
else: else:
log("[LoRa Process] No active cooking state to toggle pause/resume.") 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) await asyncio.sleep_ms(50)
async def cooking_loop_task(): 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: while True:
if cooking_state is not None: if cooking_state is not None:
cooking_state.update_tick() 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}, " 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"Paused: {cooking_state.paused}, Remaining: {cooking_state.get_remaining_time():.2f}s")
f"Est. Remaining: {cooking_state.get_remaining_time_estimation():.2f}s")
await asyncio.sleep_ms(500) await asyncio.sleep_ms(500)
@@ -215,6 +276,7 @@ async def memory_cleanup_task():
# --- BOOTSTRAP --- # --- BOOTSTRAP ---
async def main(): async def main():
global cooking_state, defrost_mode, microwave_screen
print("[Main] Starting application...") print("[Main] Starting application...")
init_hardware() init_hardware()
@@ -235,6 +297,8 @@ async def main():
print("[Main] All async tasks running concurrently!") print("[Main] All async tasks running concurrently!")
microwave_screen.update(None, defrost_mode)
# Keep main task alive indefinitely # Keep main task alive indefinitely
while True: while True:
await asyncio.sleep(3600) await asyncio.sleep(3600)
+7 -5
View File
@@ -195,14 +195,16 @@ async def uart_task():
try: try:
cmd = uart_device.read_as_command() 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) print("[UART] Command received from LoRa board:", cmd.command_type, cmd.payload)
if ( if cmd.command_type == UARTCommandType.COOKING_STATE_UPDATE:
hasattr(cmd, "command_type")
and cmd.command_type == UARTCommandType.COOKING_STATE_UPDATE
):
if cooking_state: if cooking_state:
cooking_state.set_state(cmd.payload.get("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: except Exception as e:
print("[UART Task] Error reading command:", e) print("[UART Task] Error reading command:", e)
+7 -7
View File
@@ -127,12 +127,13 @@ async def mqtt_listener_task():
def button_callback(): def button_callback():
"""Button physical interrupt callback.""" """Button physical interrupt callback."""
global button_state 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'.") print("[Button] Toggling pause/resume for microwave '2'.")
lora.send_reliable({"id": DEVICE_ID, "microwave_id": "2", "action": LoraCommands.TOGGLE_PAUSE}) lora.send_reliable({"id": DEVICE_ID, "microwave_id": "2", "action": LoraCommands.TOGGLE_PAUSE})
else: else:
button_state = not button_state button_state = not button_state
print(f"[Button] Defrost state toggled to: {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.set_callback(button_callback)
button.start_button_monitoring_thread() 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) ir_data_events.pop(microwave_id, None)
return return
# 5. Wait for MQTT IR data (if it already arrived, event.wait() returns instantly) # 5. Wait for MQTT IR data
try: try:
await asyncio.wait_for(event.wait(), timeout=10.0) await asyncio.wait_for(event.wait(), timeout=10.0)
ir_payload = ir_data_cache.get(microwave_id, {}) ir_payload = ir_data_cache.get(microwave_id, {})
sensors_data["ir_initial_temp"] = ir_payload.get("dish_temp") sensors_data["ir_initial_temp"] = ir_payload.get("dish_temp")
sensors_data["ir_ambient_temp"] = ir_payload.get("ambient_temp") sensors_data["ir_ambient_temp"] = ir_payload.get("ambient_temp")
print(f"[{microwave_id}] IR data synchronized successfully: {ir_payload}") 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: except asyncio.TimeoutError:
print(f"[{microwave_id}] ⚠️ Timeout waiting for MQTT IR data from ESP32.") print(f"[{microwave_id}] ⚠️ Timeout waiting for MQTT IR data from ESP32.")
sensors_data["ir_initial_temp"] = None sensors_data["ir_initial_temp"] = None
@@ -213,9 +216,6 @@ async def handle_new_dish(microwave_id, detected_height):
finally: finally:
ir_data_events.pop(microwave_id, None) 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): async def request_cloud_cooking_plan(microwave_id, sensors_data):
"""Sends all data to the cloud with up to 3 retries (30s interval).""" """Sends all data to the cloud with up to 3 retries (30s interval)."""
global cloud_alert 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 cloud_alert = False # Reset alert flag on success
microwave_states[microwave_id] = MicrowaveState.COOKING microwave_states[microwave_id] = MicrowaveState.COOKING
if config.DEBUG: if config.DEBUG:
c_time = min(c_time, 10) # Limit to 10s for debug c_time = 20 # Set to 20s for debug
c_temp = min(c_temp, 50) # Limit to 50°C for debug c_temp = 50 # Set to 50°C for debug
mqtt_client.publish( mqtt_client.publish(
config.MQTT_TOPIC_COOKING, config.MQTT_TOPIC_COOKING,
payloads.mqtt_cooking_config(microwave_id, c_time, c_power, c_temp), payloads.mqtt_cooking_config(microwave_id, c_time, c_power, c_temp),
+69 -27
View File
@@ -3,6 +3,7 @@ import time
class CookingState: class CookingState:
TEMPERATURE_TOLERANCE = 1.0 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): def __init__(self, cook_time: int, power_level: int, target_temp: float, temperature_provider=None, on_state_change=None, on_refresh=None):
self.cook_time = cook_time self.cook_time = cook_time
@@ -27,6 +28,9 @@ class CookingState:
self._last_refresh_signature = None self._last_refresh_signature = None
self._stirred = False self._stirred = False
# Moving average filter for heating rate (°C / sec)
self._smoothed_heating_rate = 0.0
def set_temperature_provider(self, temperature_provider): def set_temperature_provider(self, temperature_provider):
self.temperature_provider = temperature_provider self.temperature_provider = temperature_provider
@@ -45,9 +49,9 @@ class CookingState:
self.paused = True self.paused = True
self._pause_started_at = time.time() self._pause_started_at = time.time()
# self._notify_refresh(force=True)
if self.on_pause: if self.on_pause:
self.on_pause(self) self.on_pause(self)
def unpause(self): def unpause(self):
if not self.paused: if not self.paused:
return return
@@ -56,15 +60,14 @@ class CookingState:
if self._pause_started_at is not None: if self._pause_started_at is not None:
self._paused_duration += now - self._pause_started_at self._paused_duration += now - self._pause_started_at
# self._pause_started_at = None
self.paused = False self.paused = False
# self._notify_refresh(force=True)
def toggle_pause(self): def toggle_pause(self):
if self.paused: if self.paused:
self.unpause() self.unpause()
else: else:
self.pause() self.pause()
if self.on_pause:
self.on_pause(self) self.on_pause(self)
def set_state(self, state): def set_state(self, state):
@@ -90,20 +93,56 @@ class CookingState:
def get_remaining_time_estimation(self) -> float: def get_remaining_time_estimation(self) -> float:
elapsed_time = self.get_elapsed_time() 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) 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 return timer_remaining
if self.current_dish_temp >= self.target_temp: # 2. Prevent hitting 00:00 before stirring trigger:
return timer_remaining # 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() heating_rate = self._estimate_heating_rate()
if heating_rate <= 0: temp_needed = self.target_temp - self.current_dish_temp
return timer_remaining
target_remaining = (self.target_temp - self.current_dish_temp) / heating_rate if heating_rate > 0.01:
return max(timer_remaining, max(0.0, target_remaining)) 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): def _read_temperatures(self):
if self.temperature_provider is None: if self.temperature_provider is None:
@@ -118,27 +157,29 @@ class CookingState:
raise ValueError("temperature_provider must return a pair: (dish_temp, ambient_temp)") 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: 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 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 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): def _notify_state_change(self):
if self.on_state_change is None: if self.on_state_change is None:
return return
self.on_state_change(self) self.on_state_change(self)
def _notify_refresh(self, force=False): def _notify_refresh(self, force=False):
@@ -177,21 +218,22 @@ class CookingState:
now = time.time() now = time.time()
elapsed_time = self.get_elapsed_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 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.state = CookingStates.STIRRING_REQUIRED
self.pause() self.pause()
elif elapsed_time >= self.cook_time and self.current_dish_temp >= (self.target_temp - self.TEMPERATURE_TOLERANCE): elif elapsed_time >= self.cook_time and self.current_dish_temp >= (self.target_temp - self.TEMPERATURE_TOLERANCE):
self.state = CookingStates.DONE 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.state = CookingStates.STIRRING_REQUIRED
self.pause() 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.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: if self.state != previous_state:
self._notify_state_change() self._notify_state_change()
+3 -3
View File
@@ -33,10 +33,9 @@ class BaseLoraDevice:
"""Sends an immediate acknowledgement packet back to the sender.""" """Sends an immediate acknowledgement packet back to the sender."""
print(f"[ReliableLoRa] -> Triggering ACK send for msg_id: {ack_id}") print(f"[ReliableLoRa] -> Triggering ACK send for msg_id: {ack_id}")
if IS_MICROPYTHON: if IS_MICROPYTHON:
time.sleep_ms(100) time.sleep_ms(50)
else: else:
# Give LA66 chip time to finish logging RSSI and reset RF frontend time.sleep(0.05) # Reduced delay to fit ESP32 RX window
time.sleep(0.25)
ack_payload = {"_type": "_ack", "_ack_id": ack_id} ack_payload = {"_type": "_ack", "_ack_id": ack_id}
self.send(ack_payload) self.send(ack_payload)
@@ -425,3 +424,4 @@ class LoraCommands:
PING = "ping" PING = "ping"
COOKING_STATE_UPDATE = "cooking_state_update" COOKING_STATE_UPDATE = "cooking_state_update"
TOGGLE_PAUSE = "toggle_pause" TOGGLE_PAUSE = "toggle_pause"
TOGGLE_DEFROST = "toggle_defrost"
+6
View File
@@ -39,6 +39,12 @@ def mqtt_sensor_data(id_microwave, dish_temp, ambient_temp):
"ambient_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): def mqtt_cooking_config(id_microwave, cook_time, power_level, target_temp):
return as_json({ return as_json({
"id_microwave": id_microwave, "id_microwave": id_microwave,
+3
View File
@@ -75,6 +75,8 @@ class RGBLED:
"""Sets the RGB color tuple (e.g., (255, 128, 0)).""" """Sets the RGB color tuple (e.g., (255, 128, 0))."""
if isinstance(rgb_tuple, (tuple, list)) and len(rgb_tuple) == 3: if isinstance(rgb_tuple, (tuple, list)) and len(rgb_tuple) == 3:
self._color = tuple(rgb_tuple) 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() self._apply()
else: else:
raise ValueError("Color must be a tuple of 3 integers: (R, G, B)") raise ValueError("Color must be a tuple of 3 integers: (R, G, B)")
@@ -144,6 +146,7 @@ class RGBLED:
if self._is_blinking: if self._is_blinking:
self._timer.deinit() self._timer.deinit()
self._is_blinking = False self._is_blinking = False
self.on() # Ensures LED remains ON steadily after blinking stops
def blink_toggle(self, interval_ms=500): def blink_toggle(self, interval_ms=500):
"""Toggles blinking state (starts if stopped, stops if active).""" """Toggles blinking state (starts if stopped, stops if active)."""
+2
View File
@@ -98,3 +98,5 @@ class UARTCommandType:
"""Enumeration of known UART command types.""" """Enumeration of known UART command types."""
COOKING_PARAMS = "COOKING_PARAMS" COOKING_PARAMS = "COOKING_PARAMS"
COOKING_STATE_UPDATE = "COOKING_STATE_UPDATE" COOKING_STATE_UPDATE = "COOKING_STATE_UPDATE"
TEMPERATURE_REQUEST = "TEMPERATURE_REQUEST"
TEMPERATURE_RESPONSE = "TEMPERATURE_RESPONSE"