From 433c03035b509f8002bdb034a7960d540f4c2ac6 Mon Sep 17 00:00:00 2001 From: thekiwismarthome Date: Sun, 1 Mar 2026 20:15:29 +1300 Subject: [PATCH 1/3] feat: Add websocket command to search for products by barcode. --- .../shopping_list_manager/__init__.py | 4 ++++ .../websocket/handlers.py | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/custom_components/shopping_list_manager/__init__.py b/custom_components/shopping_list_manager/__init__.py index f1b66a5..64f928a 100644 --- a/custom_components/shopping_list_manager/__init__.py +++ b/custom_components/shopping_list_manager/__init__.py @@ -171,6 +171,10 @@ async def _async_register_websocket_handlers( ) # Products handlers + websocket_api.async_register_command( + hass, + handlers.websocket_search_by_barcode, + ) websocket_api.async_register_command( hass, handlers.websocket_search_products, diff --git a/custom_components/shopping_list_manager/websocket/handlers.py b/custom_components/shopping_list_manager/websocket/handlers.py index f00c527..0892478 100644 --- a/custom_components/shopping_list_manager/websocket/handlers.py +++ b/custom_components/shopping_list_manager/websocket/handlers.py @@ -782,6 +782,28 @@ def websocket_get_list_total( # PRODUCT HANDLERS # ============================================================================= +@websocket_api.websocket_command( + { + vol.Required("type"): "shopping_list_manager/products/search_by_barcode", + vol.Required("barcode"): str, + } +) +@callback +def websocket_search_by_barcode( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: Dict[str, Any], +) -> None: + """Find a single product by exact barcode match.""" + storage = get_storage(hass) + barcode = msg["barcode"].strip() + match = next( + (p for p in storage._products.values() if p.barcode and p.barcode == barcode), + None, + ) + connection.send_result(msg["id"], {"product": match.to_dict() if match else None}) + + @websocket_api.websocket_command( { vol.Required("type"): "shopping_list_manager/products/substitutes", From ae7717a8eb10ad46b9a06cf4198195954a0fc5d0 Mon Sep 17 00:00:00 2001 From: thekiwismarthome Date: Sun, 1 Mar 2026 21:20:09 +1300 Subject: [PATCH 2/3] feat: Add WebSocket endpoint to download, process, and save product images locally as WebP. --- .../shopping_list_manager/__init__.py | 4 ++ .../websocket/handlers.py | 61 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/custom_components/shopping_list_manager/__init__.py b/custom_components/shopping_list_manager/__init__.py index 64f928a..6aa18cd 100644 --- a/custom_components/shopping_list_manager/__init__.py +++ b/custom_components/shopping_list_manager/__init__.py @@ -171,6 +171,10 @@ async def _async_register_websocket_handlers( ) # Products handlers + websocket_api.async_register_command( + hass, + handlers.websocket_download_product_image, + ) websocket_api.async_register_command( hass, handlers.websocket_search_by_barcode, diff --git a/custom_components/shopping_list_manager/websocket/handlers.py b/custom_components/shopping_list_manager/websocket/handlers.py index 0892478..8f944b4 100644 --- a/custom_components/shopping_list_manager/websocket/handlers.py +++ b/custom_components/shopping_list_manager/websocket/handlers.py @@ -1,14 +1,23 @@ """WebSocket API handlers for Shopping List Manager.""" +import io import logging +import re +from pathlib import Path from typing import Any, Dict import voluptuous as vol +from aiohttp import ClientTimeout +from PIL import Image from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession from ..const import DOMAIN from ..const import ( + IMAGE_SIZE, + IMAGE_QUALITY, + IMAGES_LOCAL_DIR, WS_TYPE_LISTS_GET_ALL, WS_TYPE_LISTS_CREATE, WS_TYPE_LISTS_UPDATE, @@ -782,6 +791,58 @@ def websocket_get_list_total( # PRODUCT HANDLERS # ============================================================================= +@websocket_api.websocket_command( + { + vol.Required("type"): "shopping_list_manager/products/download_image", + vol.Required("image_url"): str, + vol.Required("product_name"): str, + } +) +@websocket_api.async_response +async def websocket_download_product_image( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: Dict[str, Any], +) -> None: + """Download a remote image and save it as WebP to the local images directory.""" + raw_url: str = msg["image_url"] + product_name: str = msg["product_name"] + + safe_stem = re.sub(r"[^a-z0-9_]", "", product_name.lower().replace(" ", "_")) or "product" + filename = f"{safe_stem}.webp" + images_dir = Path(hass.config.path(IMAGES_LOCAL_DIR)) + images_dir.mkdir(parents=True, exist_ok=True) + dest = images_dir / filename + + try: + session = async_get_clientsession(hass) + async with session.get(raw_url, timeout=ClientTimeout(total=10)) as resp: + if resp.status != 200: + connection.send_error(msg["id"], "download_failed", f"HTTP {resp.status}") + return + raw = await resp.read() + except Exception as exc: # noqa: BLE001 + connection.send_error(msg["id"], "download_failed", str(exc)) + return + + try: + img = Image.open(io.BytesIO(raw)) + if img.mode not in ("RGB", "RGBA"): + img = img.convert("RGBA") + img.thumbnail((IMAGE_SIZE, IMAGE_SIZE), Image.LANCZOS) + out = io.BytesIO() + img.save(out, format="WEBP", quality=IMAGE_QUALITY) + dest.write_bytes(out.getvalue()) + except Exception as exc: # noqa: BLE001 + connection.send_error(msg["id"], "conversion_failed", str(exc)) + return + + connection.send_result( + msg["id"], + {"local_url": f"/local/shopping_list_manager/images/{filename}"}, + ) + + @websocket_api.websocket_command( { vol.Required("type"): "shopping_list_manager/products/search_by_barcode", From 2b11632253e7d3831310b297ef0a8b9a8630674b Mon Sep 17 00:00:00 2001 From: thekiwismarthome Date: Sun, 1 Mar 2026 22:24:14 +1300 Subject: [PATCH 3/3] feat: Improve image download robustness with a User-Agent header and extended timeout, and enhance WebP conversion by ensuring images are in RGB mode. --- .../shopping_list_manager/websocket/handlers.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/custom_components/shopping_list_manager/websocket/handlers.py b/custom_components/shopping_list_manager/websocket/handlers.py index 8f944b4..db71127 100644 --- a/custom_components/shopping_list_manager/websocket/handlers.py +++ b/custom_components/shopping_list_manager/websocket/handlers.py @@ -816,7 +816,8 @@ async def websocket_download_product_image( try: session = async_get_clientsession(hass) - async with session.get(raw_url, timeout=ClientTimeout(total=10)) as resp: + headers = {"User-Agent": "Mozilla/5.0 (compatible; HomeAssistant/ShoppingListManager)"} + async with session.get(raw_url, timeout=ClientTimeout(total=15), headers=headers) as resp: if resp.status != 200: connection.send_error(msg["id"], "download_failed", f"HTTP {resp.status}") return @@ -827,8 +828,14 @@ async def websocket_download_product_image( try: img = Image.open(io.BytesIO(raw)) - if img.mode not in ("RGB", "RGBA"): - img = img.convert("RGBA") + # Convert to RGB for reliable lossy WebP encoding + # (RGBA, palette, grayscale modes can fail or produce oversized files) + if img.mode == "RGBA": + bg = Image.new("RGB", img.size, (255, 255, 255)) + bg.paste(img, mask=img.split()[3]) + img = bg + elif img.mode != "RGB": + img = img.convert("RGB") img.thumbnail((IMAGE_SIZE, IMAGE_SIZE), Image.LANCZOS) out = io.BytesIO() img.save(out, format="WEBP", quality=IMAGE_QUALITY)