Compare commits

..

3 Commits

Author SHA1 Message Date
Ninluc f6b29c4e08 Don't crash if no internet connection
Build, push image, and notify Watchtower / build-image (push) Successful in 3m37s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-11 14:15:26 +02:00
Ninluc fe586cf2a5 Try to get structured output from AI 2026-08-11 14:15:14 +02:00
Ninluc dff6cb9d41 Bigger timeout to get the cooking plan 2026-08-10 17:10:32 +02:00
5 changed files with 151 additions and 98 deletions
+81 -59
View File
@@ -1,102 +1,124 @@
import os import os
import base64 import base64
import json import json
import urllib.request from typing import Any, Union
import urllib.error from openai import OpenAI
from pydantic import BaseModel
API_HOST = os.getenv("OPENAI_API_HOST", "https://chat.matthiasg.dev/ollama") # Default base_url pointing to Lemonade's OpenAI-compatible endpoint
AI_MODEL = os.getenv("OPENAI_MODEL", "llava:7b-v1.6-mistral-q4_1") API_HOST = os.getenv("OPENAI_API_HOST", "https://lemonade.matthiasg.dev/api/v1")
AI_MODEL_THINK = os.getenv("OPENAI_MODEL_THINK", "True").lower() in ("true", "1", "t") AI_MODEL = os.getenv("OPENAI_MODEL", "LFM2.5-VL-1.6B-GGUF-Q8_0")
OPENAPI_TOKEN = os.getenv("OPENAI_API_TOKEN", None) AI_MODEL_THINK = os.getenv("OPENAI_MODEL_THINK", "False").lower() in ("true", "1", "t")
OPENAPI_ENDPOINT = "/api/generate" OPENAPI_TOKEN = os.getenv("OPENAI_API_TOKEN", "lemonade")
print(f"Using API Host: {API_HOST}") print(f"Using API Host: {API_HOST}")
print(f"Using API Model: {AI_MODEL}") print(f"Using API Model: {AI_MODEL}")
print(f"Using API Model Think: {AI_MODEL_THINK}") print(f"Using API Model Think: {AI_MODEL_THINK}")
print(f"Using API Token: {'Yes' if OPENAPI_TOKEN else 'No'} {OPENAPI_TOKEN[:5] + '...' if OPENAPI_TOKEN else ''}") print(f"Using API Token: {'Yes' if OPENAPI_TOKEN else 'No'} {OPENAPI_TOKEN[:5] + '...' if OPENAPI_TOKEN else ''}")
def call_api(body: dict, endpoint: str = OPENAPI_ENDPOINT) -> str: # Initialize OpenAI Client for Lemonade
"""Call the API with the given endpoint and body dict.""" client = OpenAI(
url = f"{API_HOST}{endpoint}" base_url=API_HOST,
headers = { api_key=OPENAPI_TOKEN if OPENAPI_TOKEN else "lemonade"
"Content-Type": "application/json", )
}
if OPENAPI_TOKEN:
headers["Authorization"] = f"Bearer {OPENAPI_TOKEN}"
json_data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(url, data=json_data, headers=headers, method="POST")
try:
with urllib.request.urlopen(req) as response:
return response.read().decode("utf-8")
except urllib.error.HTTPError as e:
error_body = e.read().decode("utf-8")
raise Exception(f"Error calling API: HTTP {e.code} - {error_body}")
except urllib.error.URLError as e:
raise Exception(f"Failed to reach server: {e.reason}")
def generate( def generate(
model: str = AI_MODEL, model: str = AI_MODEL,
prompt: str = "", prompt: str = "",
images: list[str] = None, images: list[str] = None,
output_format: str = None, output_format: Union[str, dict, type[BaseModel]] = None,
system_message: str = None, system_message: str = None,
keep_alive: bool = True, keep_alive: bool = True,
should_think: bool = AI_MODEL_THINK, should_think: bool = AI_MODEL_THINK,
) -> str: ) -> str:
""" """
Generate a response for a given prompt with a provided model via the Ollama/OpenAI API. Generate a completion response using the OpenAI SDK against Lemonade / OpenAI compatible APIs.
Handles base64 encoding for local image file paths and structures the request body. Handles multimodal image content, structured outputs, and custom Lemonade parameters.
""" """
if images is None: if images is None:
images = [] images = []
# Transform image file paths to base64 strings messages = []
encoded_images = []
# 1. Add System Message if present
if system_message:
messages.append({"role": "system", "content": system_message})
# 2. Build User Content Payload (Text + Multimodal Images)
user_content = []
if prompt:
user_content.append({"type": "text", "text": prompt})
for img_path in images: for img_path in images:
if os.path.isfile(img_path): if os.path.isfile(img_path):
with open(img_path, "rb") as image_file: with open(img_path, "rb") as image_file:
encoded_images.append(base64.b64encode(image_file.read()).decode("utf-8")) b64_str = base64.b64encode(image_file.read()).decode("utf-8")
else: else:
# If it's already a base64 string or an invalid path, keep as-is b64_str = img_path
encoded_images.append(img_path)
body = { # Ensure base64 string includes Data URI prefix for OpenAI vision format
"model": model, if not b64_str.startswith("data:"):
"prompt": prompt, image_url = f"data:image/jpeg;base64,{b64_str}"
"images": encoded_images, else:
image_url = b64_str
user_content.append({
"type": "image_url",
"image_url": {"url": image_url}
})
# Simplify content payload if text-only
if len(user_content) == 1 and user_content[0]["type"] == "text":
messages.append({"role": "user", "content": prompt})
else:
messages.append({"role": "user", "content": user_content})
# 3. Prepare parameters and custom body flags
extra_body = {
"think": should_think, "think": should_think,
"stream": False, }
if not keep_alive:
extra_body["keep_alive"] = "0m"
request_kwargs: dict[str, Any] = {
"model": model,
"messages": messages,
"extra_body": extra_body,
} }
if system_message is not None: # 4. Handle Structured Outputs / JSON mode
body["system"] = system_message
if output_format is not None: if output_format is not None:
try: if isinstance(output_format, type) and issubclass(output_format, BaseModel):
body["format"] = json.loads(output_format) # Pydantic Model -> Use structured output parsing API
except json.JSONDecodeError: response = client.beta.chat.completions.parse(
body["format"] = output_format response_format=output_format,
**request_kwargs
)
return response.choices[0].message.content or ""
if not keep_alive: elif isinstance(output_format, dict):
body["keep_alive"] = "0m" # JSON Schema dictionary
request_kwargs["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": "structured_response",
"strict": True,
"schema": output_format,
}
}
elif isinstance(output_format, str) and output_format.lower() == "json":
# Standard JSON Mode
request_kwargs["response_format"] = {"type": "json_object"}
response_text = call_api(body) # Standard completion request
response = client.chat.completions.create(**request_kwargs)
try: return response.choices[0].message.content or ""
decoded_response = json.loads(response_text)
except json.JSONDecodeError as e:
raise Exception(f"Error decoding JSON response: {e}")
return decoded_response.get("response", "")
if __name__ == "__main__": if __name__ == "__main__":
# Example usage:
result = generate( result = generate(
prompt="Explain what you see in the image or answer this prompt.", prompt="Explain what you see or answer this prompt.",
should_think=AI_MODEL_THINK, should_think=AI_MODEL_THINK,
) )
print(result) print(result)
+4
View File
@@ -84,6 +84,10 @@ async def cooking_params():
# 2. Run Vision Safety Check & Cook Planner Concurrently # 2. Run Vision Safety Check & Cook Planner Concurrently
try: try:
if getattr(config, "DEBUG", True):
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) safety_task = asyncio.to_thread(safety_checker.check_dish_safety, filepath)
planner_task = asyncio.to_thread( planner_task = asyncio.to_thread(
microwave_cook_planner.generate_plan, microwave_cook_planner.generate_plan,
+2
View File
@@ -4,3 +4,5 @@ gunicorn==21.2.0
opencv-python-headless opencv-python-headless
requests==2.32.3 requests==2.32.3
paho-mqtt>=1.6,<3 paho-mqtt>=1.6,<3
openai>=1.0.0
pydantic>=2.0.0
+30 -24
View File
@@ -1,27 +1,20 @@
import json import json
from pydantic import BaseModel, Field
from APIs.aichat import generate from APIs.aichat import generate
DISH_SAFETY_SCHEMA = {
"type": "object", # 1. Define the output schema as a Pydantic model
"properties": { class DishSafetyResult(BaseModel):
"is_safe": { is_safe: bool = Field(
"type": "boolean", description="True if no microwave hazards (metal, foil, sealed packaging) are present."
"description": "True if no microwave hazards (metal, foil, sealed packaging) are present." )
}, warning_message: str = Field(
"warning_message": { description="Explanation of any hazard found, or an empty string if safe."
"type": "string", )
"description": "Explanation of any hazard found, or an empty string if safe." detected_hazards: list[str] = Field(
}, description="List of specific hazard items detected."
"detected_hazards": { )
"type": "array",
"items": {
"type": "string"
},
"description": "List of specific hazard items detected."
}
},
"required": ["is_safe", "warning_message", "detected_hazards"]
}
def check_dish_safety(image_path: str) -> dict: def check_dish_safety(image_path: str) -> dict:
prompt = ( prompt = (
@@ -29,15 +22,28 @@ def check_dish_safety(image_path: str) -> dict:
"Inspect the area for metal utensils, aluminum foil, metallic dish patterns, or unvented plastic wraps." "Inspect the area for metal utensils, aluminum foil, metallic dish patterns, or unvented plastic wraps."
) )
# Pass the schema directly to the generation call # 2. Pass the Pydantic class directly to generate()
response_raw = generate( response_raw = generate(
prompt=prompt, prompt=prompt,
images=[image_path], images=[image_path],
output_format=json.dumps(DISH_SAFETY_SCHEMA) output_format=DishSafetyResult,
should_think=False
) )
print(f"[Debug] Raw response from AI generator: {response_raw}") print(f"[Debug] Raw response from AI generator: {response_raw}")
# 1. If response_raw is already a dict, return it directly # 3. Handle response parsing
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):
return response_raw.model_dump()
if isinstance(response_raw, dict): if isinstance(response_raw, dict):
return response_raw return response_raw
+31 -12
View File
@@ -106,9 +106,12 @@ cloud_mqtt_client = get_mqtt_client(
password="myMicrowaveVerySecret", password="myMicrowaveVerySecret",
keepalive=config.MQTT_KEEPALIVE, keepalive=config.MQTT_KEEPALIVE,
) )
cloud_mqtt_client.connect() try:
cloud_mqtt_client.subscribe("cmd/all", qos=config.CLOUD_MQTT_QOS) cloud_mqtt_client.connect()
cloud_mqtt_client.subscribe(f"cmd/{DEVICE_ID}", qos=config.CLOUD_MQTT_QOS) cloud_mqtt_client.subscribe("cmd/all", qos=config.CLOUD_MQTT_QOS)
cloud_mqtt_client.subscribe(f"cmd/{DEVICE_ID}", qos=config.CLOUD_MQTT_QOS)
except Exception as e:
print(f"[MQTT Cloud] Error connecting to cloud MQTT broker: {e}")
if hasattr(cloud_mqtt_client._client, "loop_start"): if hasattr(cloud_mqtt_client._client, "loop_start"):
cloud_mqtt_client._client.loop_start() cloud_mqtt_client._client.loop_start()
@@ -264,7 +267,7 @@ async def handle_new_dish(microwave_id, detected_height):
ir_data_events.pop(microwave_id, None) ir_data_events.pop(microwave_id, None)
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 (330s timeout for AI generation)."""
global cloud_alert global cloud_alert
microwave_states[microwave_id] = MicrowaveState.WAITING_FOR_CLOUD microwave_states[microwave_id] = MicrowaveState.WAITING_FOR_CLOUD
URL = "https://smartwave.matthiasg.dev/cooking-params" URL = "https://smartwave.matthiasg.dev/cooking-params"
@@ -276,7 +279,10 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
print(f"[{microwave_id}] Requesting cooking plan from cloud app...") print(f"[{microwave_id}] Requesting cooking plan from cloud app...")
max_retries = 3 max_retries = 3
retry_delay_seconds = 30 retry_delay_seconds = 10 # Reduced delay since each request already waits up to 5 mins
# Tuple format: (connect_timeout_seconds, read_timeout_seconds)
HTTP_TIMEOUT = (10, 330)
for attempt in range(1, max_retries + 1): for attempt in range(1, max_retries + 1):
if microwave_states.get(microwave_id) != MicrowaveState.WAITING_FOR_CLOUD: if microwave_states.get(microwave_id) != MicrowaveState.WAITING_FOR_CLOUD:
@@ -284,30 +290,43 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
return return
try: try:
print(f"[{microwave_id}] Connection attempt {attempt}/{max_retries}...") 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, timeout=30)
# Abort if state changed (e.g. user removed dish while waiting for wifi) response = await asyncio.to_thread(
requests.post,
URL,
json=sensors_data,
timeout=HTTP_TIMEOUT
)
# Abort if state changed while waiting for cloud AI response
if microwave_states.get(microwave_id) != MicrowaveState.WAITING_FOR_CLOUD: if microwave_states.get(microwave_id) != MicrowaveState.WAITING_FOR_CLOUD:
print(f"[{microwave_id}] Dish removed during API request. Discarding API plan.") print(f"[{microwave_id}] Dish removed during API request. Discarding API plan.")
return return
response.raise_for_status() response.raise_for_status()
plan = response.json().get("cook_plan", {})
# Robust JSON extraction: handles direct dict or nested {"cook_plan": {...}}
res_json = response.json()
plan = res_json.get("cook_plan", res_json)
c_time = plan.get("cook_time_seconds") c_time = plan.get("cook_time_seconds")
c_power = plan.get("effective_power_watts") c_power = plan.get("effective_power_watts")
c_temp = plan.get("target_temp") c_temp = plan.get("target_temp")
if c_time is None or c_power is None or c_temp is None: if c_time is None or c_power is None or c_temp is None:
print(f"[{microwave_id}] ❌ Invalid plan received: {response.json()}") print(f"[{microwave_id}] ❌ Invalid plan received: {res_json}")
microwave_states[microwave_id] = MicrowaveState.DONE microwave_states[microwave_id] = MicrowaveState.DONE
return return
print(f"[{microwave_id}] Cloud Plan Received! Starting microwave: {c_time}s @ {c_power}W") print(f"[{microwave_id}] Cloud Plan Received! Starting microwave: {c_time}s @ {c_power}W")
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 = 20 # Set to 20s for debug c_time = 20 # Set to 20s for debug
c_temp = 50 # Set 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),
@@ -320,7 +339,7 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
if attempt < max_retries: if attempt < max_retries:
print(f"[{microwave_id}] Retrying in {retry_delay_seconds} seconds...") print(f"[{microwave_id}] Retrying in {retry_delay_seconds} seconds...")
# Interruptible wait loop in case user removes the dish mid-wait # Interruptible wait loop in case state changes mid-wait
for _ in range(retry_delay_seconds): for _ in range(retry_delay_seconds):
if microwave_states.get(microwave_id) != MicrowaveState.WAITING_FOR_CLOUD: if microwave_states.get(microwave_id) != MicrowaveState.WAITING_FOR_CLOUD:
print(f"[{microwave_id}] State changed during retry wait. Aborting retries.") print(f"[{microwave_id}] State changed during retry wait. Aborting retries.")
@@ -328,7 +347,7 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
await asyncio.sleep(1) await asyncio.sleep(1)
# Executed only if all 3 retries failed # Executed only if all 3 retries failed
print(f"[{microwave_id}] All 3 cloud retries failed. Setting global alert flag.") print(f"[{microwave_id}] All cloud retries failed. Setting global alert flag.")
cloud_alert = True cloud_alert = True
microwave_states[microwave_id] = MicrowaveState.DONE microwave_states[microwave_id] = MicrowaveState.DONE