diff --git a/custom_components/opendisplay/imagegen/core.py b/custom_components/opendisplay/imagegen/core.py index 0898e2e..644771e 100644 --- a/custom_components/opendisplay/imagegen/core.py +++ b/custom_components/opendisplay/imagegen/core.py @@ -303,54 +303,62 @@ def should_show_element(element: dict) -> bool: return element.get("visible", True) - async def generate_custom_image( + async def _async_prefetch_resources(self, payload: list) -> None: + """Pre-fetch async resources needed by elements in *payload*. + + Only elements that require data from the HA event loop (recorder history) + are handled here. All other I/O (HTTP downloads, local files) is + performed directly inside the executor thread during the sync drawing + phase. + + Results are stored inside each element dict under a private ``_*`` key + so that the synchronous draw handlers can retrieve them without needing + access to the event loop. + """ + for element in payload: + if not self.should_show_element(element): + continue + + element_type_str = element.get("type") + if not element_type_str: + continue + + try: + element_type = ElementType(element_type_str) + except ValueError: + continue + + if element_type == ElementType.PLOT: + from .visualizations import prefetch_plot_data + element["_plot_data"] = await prefetch_plot_data(self.hass, element) + + def _generate_sync( self, - entity_id: str, + canvas_width: int, + canvas_height: int, service_data: Dict[str, Any], - error_collector: list = None, - *, - width: int, - height: int, + payload: list, + error_collector: list, accent_color: str, ) -> bytes: - """Generate a custom image based on service data. + """Synchronous image generation – runs in an executor thread. - Main entry point for image generation. Creates an image with the - specified elements and returns the JPEG data. + All PIL drawing, QR-code generation, font loading, and JPEG encoding + happen here, completely off the Home Assistant event loop. Args: - entity_id: The entity ID to generate the image for - service_data: Service data containing image parameters and payload - error_collector: Optional list to collect error messages - width: Canvas width in pixels - height: Canvas height in pixels + canvas_width: Canvas width in pixels + canvas_height: Canvas height in pixels + service_data: Original service data (for background color, rotate, etc.) + payload: List of element dicts (may contain pre-fetched ``_*`` keys) + error_collector: List to append per-element error messages to accent_color: Accent color name Returns: bytes: JPEG image data - - Raises: - HomeAssistantError: If image generation fails """ - - error_collector = error_collector if error_collector is not None else [] - - canvas_width = width - canvas_height = height - - # Validate dimensions to prevent PIL errors - if canvas_width <= 0 or canvas_height <= 0: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="invalid_canvas_dimensions", - translation_placeholders={"width": canvas_width, "height": canvas_height, "entity_id": entity_id} - ) - - _LOGGER.debug("Canvas dimensions for %s: %dx%d", entity_id, canvas_width, canvas_height) - colors = ColorResolver(accent_color) - # Get rotation and create base image rotate = service_data.get("rotate", 0) if rotate in (0, 180): img = Image.new('RGBA', (canvas_width, canvas_height), @@ -359,15 +367,13 @@ async def generate_custom_image( img = Image.new('RGBA', (canvas_height, canvas_width), color=colors.resolve(service_data.get("background", "white"))) - payload = service_data.get("payload", []) - ctx = DrawingContext( img=img, colors=colors, coords=CoordinateParser(img.width, img.height), fonts=self._font_manager, hass=self.hass, - pos_y=0 + pos_y=0, ) for i, element in enumerate(payload): @@ -375,15 +381,13 @@ async def generate_custom_image( continue try: - # Get element type if "type" not in element: raise ValueError("Element missing required 'type' field") element_type = ElementType(element["type"]) - # Get the appropriate handler and call it handler = self._draw_handlers.get(element_type) if handler: - await handler(ctx, element) + handler(ctx, element) else: error_msg = f"No handler found for element type: {element_type}" _LOGGER.warning(error_msg) @@ -399,16 +403,79 @@ async def generate_custom_image( _LOGGER.error(error_msg) error_collector.append(error_msg) continue - # Apply rotation if needed + if rotate: img = img.rotate(rotate, expand=True) - # Convert to RGB for JPEG rgb_image = img.convert('RGB') - # Create BytesIO object for the JPEG data img_byte_arr = io.BytesIO() rgb_image.save(img_byte_arr, format='JPEG', quality="maximum") - image_data = img_byte_arr.getvalue() + return img_byte_arr.getvalue() + + async def generate_custom_image( + self, + entity_id: str, + service_data: Dict[str, Any], + error_collector: list = None, + *, + width: int, + height: int, + accent_color: str, + ) -> bytes: + """Generate a custom image based on service data. + + Main entry point for image generation. The work is split into two phases: + + 1. **Async pre-fetch** (event loop): Collects data that requires the HA + event loop, e.g. recorder history for plot elements. + 2. **Sync generation** (executor thread): All CPU-intensive PIL operations + run in a worker thread so the event loop stays responsive. + + Args: + entity_id: The entity ID to generate the image for + service_data: Service data containing image parameters and payload + error_collector: Optional list to collect error messages + width: Canvas width in pixels + height: Canvas height in pixels + accent_color: Accent color name + + Returns: + bytes: JPEG image data + + Raises: + HomeAssistantError: If image generation fails + """ + + error_collector = error_collector if error_collector is not None else [] + + canvas_width = width + canvas_height = height + + # Validate dimensions to prevent PIL errors + if canvas_width <= 0 or canvas_height <= 0: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_canvas_dimensions", + translation_placeholders={"width": canvas_width, "height": canvas_height, "entity_id": entity_id} + ) + + _LOGGER.debug("Canvas dimensions for %s: %dx%d", entity_id, canvas_width, canvas_height) + + payload = service_data.get("payload", []) + + # Phase 1 (event loop): pre-fetch data that needs async access + await self._async_prefetch_resources(payload) + + # Phase 2 (executor thread): all CPU-intensive PIL work + image_data = await self.hass.async_add_executor_job( + self._generate_sync, + canvas_width, + canvas_height, + service_data, + payload, + error_collector, + accent_color, + ) return image_data diff --git a/custom_components/opendisplay/imagegen/debug.py b/custom_components/opendisplay/imagegen/debug.py index dccd04b..d5bfb6f 100644 --- a/custom_components/opendisplay/imagegen/debug.py +++ b/custom_components/opendisplay/imagegen/debug.py @@ -8,7 +8,7 @@ @element_handler(ElementType.DEBUG_GRID) -async def draw_debug_grid(ctx: DrawingContext, element: dict) -> None: +def draw_debug_grid(ctx: DrawingContext, element: dict) -> None: """ Draw debug grid for layout assistance. diff --git a/custom_components/opendisplay/imagegen/icons.py b/custom_components/opendisplay/imagegen/icons.py index 95b0f88..c3f8923 100644 --- a/custom_components/opendisplay/imagegen/icons.py +++ b/custom_components/opendisplay/imagegen/icons.py @@ -15,9 +15,43 @@ _LOGGER = logging.getLogger(__name__) _ASSETS_DIR = os.path.join(os.path.dirname(__file__), "assets") +# Module-level cache for MDI metadata and fonts. +# Loaded once from disk (in the executor thread) and reused on every subsequent call. +_mdi_metadata_cache: list | None = None +_mdi_font_cache: dict[int, ImageFont.FreeTypeFont] = {} + + +def _get_mdi_metadata() -> list: + """Return the MDI icon metadata, loading from disk on the first call.""" + global _mdi_metadata_cache + if _mdi_metadata_cache is None: + meta_file = os.path.join(_ASSETS_DIR, "materialdesignicons-webfont_meta.json") + with open(meta_file, "r", encoding="utf-8") as f: + _mdi_metadata_cache = json.load(f) + return _mdi_metadata_cache + + +def _get_mdi_font(size: int) -> ImageFont.FreeTypeFont: + """Return the MDI font at the given size, loading from disk on the first call.""" + if size not in _mdi_font_cache: + font_file = os.path.join(_ASSETS_DIR, "materialdesignicons-webfont.ttf") + _mdi_font_cache[size] = ImageFont.truetype(font_file, size) + return _mdi_font_cache[size] + + +def _find_icon_codepoint(mdi_data: list, icon_name: str) -> str | None: + """Search MDI metadata for *icon_name* and return its hex codepoint or None.""" + for icon in mdi_data: + if icon["name"] == icon_name: + return icon["codepoint"] + for icon in mdi_data: + if "aliases" in icon and icon_name in icon["aliases"]: + return icon["codepoint"] + return None + @element_handler(ElementType.ICON, requires=["x", "y", "value", "size"]) -async def draw_icon(ctx: DrawingContext, element: dict) -> None: +def draw_icon(ctx: DrawingContext, element: dict) -> None: """ Draw Material Design Icons. @@ -27,7 +61,6 @@ async def draw_icon(ctx: DrawingContext, element: dict) -> None: Args: ctx: Drawing context element: Element dictionary with icon properties - pos_y: Current Y position for automatic positioning Raises: HomeAssistantError: If icon name is invalid or rendering fails """ @@ -38,16 +71,9 @@ async def draw_icon(ctx: DrawingContext, element: dict) -> None: x = ctx.coords.parse_x(element['x']) y = ctx.coords.parse_y(element['y']) - # Load MDI font and metadata - font_file = os.path.join(_ASSETS_DIR, "materialdesignicons-webfont.ttf") - meta_file = os.path.join(_ASSETS_DIR, "materialdesignicons-webfont_meta.json") - + # Load MDI metadata from the module-level cache (disk read only on first call) try: - def load_meta(): - with open(meta_file, 'r', encoding='utf-8') as f: - return json.load(f) - - mdi_data = await ctx.hass.async_add_executor_job(load_meta) + mdi_data = _get_mdi_metadata() except Exception as e: raise HomeAssistantError( translation_domain=DOMAIN, @@ -60,19 +86,7 @@ def load_meta(): if icon_name.startswith("mdi:"): icon_name = icon_name[4:] - chr_hex = None - # Search direct matches - for icon in mdi_data: - if icon['name'] == icon_name: - chr_hex = icon['codepoint'] - break - - # Search aliases if no direct match - if not chr_hex: - for icon in mdi_data: - if 'aliases' in icon and icon_name in icon['aliases']: - chr_hex = icon['codepoint'] - break + chr_hex = _find_icon_codepoint(mdi_data, icon_name) if not chr_hex: raise HomeAssistantError( @@ -81,11 +95,8 @@ def load_meta(): translation_placeholders={"icon_name": icon_name} ) - # Get icon properties - def load_font(): - return ImageFont.truetype(font_file, element['size']) - - font = await ctx.hass.async_add_executor_job(load_font) + # Load font from the module-level cache (disk read only on first call per size) + font = _get_mdi_font(element['size']) anchor = element.get('anchor', "la") fill = ctx.colors.resolve( element.get('color') or element.get('fill', "black") @@ -124,7 +135,7 @@ def load_font(): @element_handler(ElementType.ICON_SEQUENCE, requires=["x", "y", "icons", "size"]) -async def draw_icon_sequence(ctx: DrawingContext, element: dict) -> None: +def draw_icon_sequence(ctx: DrawingContext, element: dict) -> None: """ Draw a sequence of icons in a specified direction. @@ -151,16 +162,9 @@ async def draw_icon_sequence(ctx: DrawingContext, element: dict) -> None: stroke_fill = ctx.colors.resolve(element.get('stroke_fill', 'white')) direction = element.get('direction', 'right') # right, down, up, left - # Load MDI font and metadata - font_file = os.path.join(_ASSETS_DIR, "materialdesignicons-webfont.ttf") - meta_file = os.path.join(_ASSETS_DIR, "materialdesignicons-webfont_meta.json") - + # Load MDI metadata and font from the module-level caches try: - def load_meta(): - with open(meta_file, 'r', encoding='utf-8') as f: - return json.load(f) - - mdi_data = await ctx.hass.async_add_executor_job(load_meta) + mdi_data = _get_mdi_metadata() except Exception as e: raise HomeAssistantError( translation_domain=DOMAIN, @@ -168,11 +172,7 @@ def load_meta(): translation_placeholders={"error": str(e)} ) - # Load font - def load_font(): - return ImageFont.truetype(font_file, size) - - font = await ctx.hass.async_add_executor_job(load_font) + font = _get_mdi_font(size) max_y = y_start max_x = x_start @@ -184,20 +184,7 @@ def load_font(): if icon_name.startswith("mdi:"): icon_name = icon_name[4:] - # Find icon codepoint - chr_hex = None - # Search direct matches - for icon in mdi_data: - if icon['name'] == icon_name: - chr_hex = icon['codepoint'] - break - - # Search aliases if no direct match - if not chr_hex: - for icon in mdi_data: - if 'aliases' in icon and icon_name in icon['aliases']: - chr_hex = icon['codepoint'] - break + chr_hex = _find_icon_codepoint(mdi_data, icon_name) if not chr_hex: _LOGGER.warning(f"Invalid icon name: {icon_name}") diff --git a/custom_components/opendisplay/imagegen/media.py b/custom_components/opendisplay/imagegen/media.py index 127b515..2fc9bc3 100644 --- a/custom_components/opendisplay/imagegen/media.py +++ b/custom_components/opendisplay/imagegen/media.py @@ -21,7 +21,7 @@ @element_handler(ElementType.QRCODE, requires=["x", "y", "data"]) -async def draw_qrcode(ctx: DrawingContext, element: dict) -> None: +def draw_qrcode(ctx: DrawingContext, element: dict) -> None: """Draw QR code element. Generates and renders a QR code with the specified data and properties. @@ -78,13 +78,16 @@ async def draw_qrcode(ctx: DrawingContext, element: dict) -> None: @element_handler(ElementType.DLIMG, requires=["x", "y", "url", "xsize", "ysize"]) -async def draw_downloaded_image(ctx: DrawingContext, element: dict) -> None: +def draw_downloaded_image(ctx: DrawingContext, element: dict) -> None: """ Draw downloaded or local image. Downloads and renders an image from a URL, or loads and renders an image from a local path or data URI. + All network and file I/O runs inside the executor thread that hosts this + call, so it never blocks the Home Assistant event loop. + Args: ctx: Drawing context element: Element dictionary with image properties @@ -99,39 +102,36 @@ async def draw_downloaded_image(ctx: DrawingContext, element: dict) -> None: rotate = element.get('rotate', 0) resize_method = element.get('resize_method', 'stretch') - # Check if URL is an image entity - if element['url'].startswith('image.') or element['url'].startswith('camera.'): - # Get state of the image entity - state = ctx.hass.states.get(element['url']) + url = element['url'] + + # Check if URL is an image entity – state access is thread-safe in HA + if url.startswith('image.') or url.startswith('camera.'): + state = ctx.hass.states.get(url) if not state: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="image_entity_not_found", - translation_placeholders={"entity_id": element['url']} + translation_placeholders={"entity_id": url} ) - # Get image URL from entity attributes image_url = state.attributes.get("entity_picture") if not image_url: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="image_entity_no_url", - translation_placeholders={"entity_id": element['url']} + translation_placeholders={"entity_id": url} ) - # If the URL is relative, make it absolute using HA's base URL if image_url.startswith("/"): base_url = get_url(ctx.hass) image_url = f"{base_url}{image_url}" - # Update URL to the actual image URL - element['url'] = image_url + url = image_url # Load image based on URL type - if element['url'].startswith(('http://', 'https://')): - # Download web image - response = await ctx.hass.async_add_executor_job( - requests.get, element['url']) + if url.startswith(('http://', 'https://')): + # Blocking HTTP download – fine because we are in an executor thread + response = requests.get(url) if response.status_code != 200: raise HomeAssistantError( translation_domain=DOMAIN, @@ -140,10 +140,10 @@ async def draw_downloaded_image(ctx: DrawingContext, element: dict) -> None: ) source_img = Image.open(io.BytesIO(response.content)) - elif element['url'].startswith('data:'): + elif url.startswith('data:'): # Handle data URI try: - header, encoded = element['url'].split(',', 1) + header, encoded = url.split(',', 1) if ';base64' in header: decoded = base64.b64decode(encoded) else: @@ -157,13 +157,13 @@ async def draw_downloaded_image(ctx: DrawingContext, element: dict) -> None: ) else: - # Handle local file - if not element['url'].startswith('/'): + # Handle local file – blocking read is fine in executor thread + if not url.startswith('/'): media_path = ctx.hass.config.path('media') - full_path = os.path.join(media_path, element['url']) + full_path = os.path.join(media_path, url) else: - full_path = element['url'] - source_img = await ctx.hass.async_add_executor_job(Image.open, full_path) + full_path = url + source_img = Image.open(full_path) # Process image if rotate: @@ -197,4 +197,4 @@ async def draw_downloaded_image(ctx: DrawingContext, element: dict) -> None: translation_domain=DOMAIN, translation_key="image_process_failed", translation_placeholders={ "error": str(e)} - ) \ No newline at end of file + ) diff --git a/custom_components/opendisplay/imagegen/registry.py b/custom_components/opendisplay/imagegen/registry.py index 2bc6f78..24f54a4 100644 --- a/custom_components/opendisplay/imagegen/registry.py +++ b/custom_components/opendisplay/imagegen/registry.py @@ -21,14 +21,14 @@ def element_handler(element_type: "ElementType", requires: list[str] | None = No def decorator(func): @wraps(func) - async def wrapper(ctx: "DrawingContext", element: dict) -> None: + def wrapper(ctx: "DrawingContext", element: dict) -> None: if requires: missing = [key for key in requires if key not in element] if missing: raise ValueError( f"{element_type.value} requires: {', '.join(missing)}" ) - return await func(ctx, element) + return func(ctx, element) _handlers[element_type] = (wrapper, requires or []) return wrapper diff --git a/custom_components/opendisplay/imagegen/shapes.py b/custom_components/opendisplay/imagegen/shapes.py index 2ab61cb..fc6c537 100644 --- a/custom_components/opendisplay/imagegen/shapes.py +++ b/custom_components/opendisplay/imagegen/shapes.py @@ -12,7 +12,7 @@ @element_handler(ElementType.LINE, requires=["x_start", "x_end"]) -async def draw_line(ctx: DrawingContext, element: dict) -> None: +def draw_line(ctx: DrawingContext, element: dict) -> None: """ Draw line element. @@ -62,7 +62,7 @@ async def draw_line(ctx: DrawingContext, element: dict) -> None: @element_handler(ElementType.RECTANGLE, requires=["x_start", "x_end", "y_start", "y_end"]) -async def draw_rectangle(ctx: DrawingContext, element: dict) -> None: +def draw_rectangle(ctx: DrawingContext, element: dict) -> None: """ Draw rectangle element. @@ -103,7 +103,7 @@ async def draw_rectangle(ctx: DrawingContext, element: dict) -> None: @element_handler(ElementType.RECTANGLE_PATTERN, requires=["x_start", "x_size", "y_start", "y_size", "x_repeat", "y_repeat", "x_offset", "y_offset"]) -async def draw_rectangle_pattern(ctx: DrawingContext, element: dict) -> None: +def draw_rectangle_pattern(ctx: DrawingContext, element: dict) -> None: """ Draw repeated rectangle pattern. @@ -152,7 +152,7 @@ async def draw_rectangle_pattern(ctx: DrawingContext, element: dict) -> None: @element_handler(ElementType.POLYGON, requires=["points"]) -async def draw_polygon(ctx: DrawingContext, element: dict) -> None: +def draw_polygon(ctx: DrawingContext, element: dict) -> None: """Draw a polygon. Renders a polygon defined by a list of vertex coordinates. @@ -182,7 +182,7 @@ async def draw_polygon(ctx: DrawingContext, element: dict) -> None: @element_handler(ElementType.CIRCLE, requires=["x", "y", "radius"]) -async def draw_circle(ctx: DrawingContext, element: dict) -> None: +def draw_circle(ctx: DrawingContext, element: dict) -> None: """Draw circle element. Renders a circle with options for fill and outline. @@ -214,7 +214,7 @@ async def draw_circle(ctx: DrawingContext, element: dict) -> None: @element_handler(ElementType.ELLIPSE, requires=["x_start", "x_end", "y_start", "y_end"]) -async def draw_ellipse(ctx: DrawingContext, element: dict) -> None: +def draw_ellipse(ctx: DrawingContext, element: dict) -> None: """ Draw ellipse element. @@ -249,7 +249,7 @@ async def draw_ellipse(ctx: DrawingContext, element: dict) -> None: @element_handler(ElementType.ARC, requires=["x", "y", "radius", "start_angle", "end_angle"]) -async def draw_arc(ctx: DrawingContext, element: dict) -> None: +def draw_arc(ctx: DrawingContext, element: dict) -> None: """Draw an arc or pie slice. Renders an arc (outline) or pie slice (filled) based on center point, diff --git a/custom_components/opendisplay/imagegen/text.py b/custom_components/opendisplay/imagegen/text.py index 027dd3e..2bba7e0 100644 --- a/custom_components/opendisplay/imagegen/text.py +++ b/custom_components/opendisplay/imagegen/text.py @@ -13,7 +13,7 @@ @element_handler(ElementType.TEXT, requires=["x", "value"]) -async def draw_text(ctx: DrawingContext, element: dict) -> None: +def draw_text(ctx: DrawingContext, element: dict) -> None: """Draw (colored) text with optional wrapping or ellipsis. Renders text with support for multiple formatting options: @@ -181,7 +181,7 @@ async def draw_text(ctx: DrawingContext, element: dict) -> None: @element_handler(ElementType.MULTILINE, requires=["x", "value", "delimiter", "offset_y"]) -async def draw_multiline(ctx: DrawingContext, element: dict) -> None: +def draw_multiline(ctx: DrawingContext, element: dict) -> None: """Draw multiline text with delimiter. Renders multiple lines of text separated by a delimiter character. diff --git a/custom_components/opendisplay/imagegen/types.py b/custom_components/opendisplay/imagegen/types.py index c6834f8..e04c2a6 100644 --- a/custom_components/opendisplay/imagegen/types.py +++ b/custom_components/opendisplay/imagegen/types.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import Enum from typing import TYPE_CHECKING @@ -75,4 +75,12 @@ class DrawingContext: coords: "CoordinateParser" fonts: "FontManager" hass: "HomeAssistant" - pos_y: int = 0 \ No newline at end of file + pos_y: int = 0 + resources: dict = field(default_factory=dict) + """Per-call pre-fetched data shared across handlers. + + Populated by the async pre-fetch phase in :meth:`ImageGen._async_prefetch_resources` + before the synchronous drawing phase runs in an executor thread. Handlers + that need shared resources (e.g. MDI icon metadata) can read from this dict + instead of performing I/O themselves. + """ \ No newline at end of file diff --git a/custom_components/opendisplay/imagegen/visualizations.py b/custom_components/opendisplay/imagegen/visualizations.py index 1d0825d..2a2e10a 100644 --- a/custom_components/opendisplay/imagegen/visualizations.py +++ b/custom_components/opendisplay/imagegen/visualizations.py @@ -4,8 +4,10 @@ import math from datetime import timedelta, datetime from functools import partial +from typing import Any from PIL import ImageDraw +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.history import get_significant_states @@ -18,20 +20,61 @@ _LOGGER = logging.getLogger(__name__) +async def prefetch_plot_data(hass: HomeAssistant, element: dict) -> dict[str, Any] | None: + """Asynchronously fetch the sensor history needed by a *plot* element. + + Must be called on the event loop **before** the synchronous drawing phase + runs in an executor thread. + + Returns a dict with keys ``all_states``, ``start``, ``end``, ``duration``, + or ``None`` when the element configuration is invalid. + """ + duration_seconds = float(element.get("duration", 60 * 60 * 24)) + if duration_seconds <= 0: + return None + + duration = timedelta(seconds=duration_seconds) + end = dt.now() + start = end - duration + + entity_ids = [plot["entity"] for plot in element.get("data", [])] + if not entity_ids: + return None + + all_states = await get_instance(hass).async_add_executor_job( + partial( + get_significant_states, + hass, + start_time=start, + entity_ids=entity_ids, + significant_changes_only=False, + minimal_response=True, + no_attributes=False, + ) + ) + + return { + "all_states": all_states, + "start": start, + "end": end, + "duration": duration, + } + + @element_handler(ElementType.PLOT, requires=["data"]) -async def draw_plot(ctx: DrawingContext, element: dict) -> None: +def draw_plot(ctx: DrawingContext, element: dict) -> None: """ Draw plot of Home Assistant sensor data. Creates a line plot visualization of historical data from Home Assistant entities with customizable axes, legends, and styling. - This is one of the most complex drawing methods, handling data retrieval, - scaling, and rendering of multiple data series and plot components. + Sensor history is pre-fetched asynchronously (see :func:`prefetch_plot_data`) + and stored in ``element["_plot_data"]`` before this sync handler is called. Args: ctx: Drawing context - element: Element dictionary with plot properties + element: Element dictionary with plot properties (must contain ``_plot_data``) Raises: HomeAssistantError: If plot generation fails """ @@ -46,16 +89,18 @@ async def draw_plot(ctx: DrawingContext, element: dict) -> None: width = x_end - x_start + 1 height = y_end - y_start + 1 - # Get time range - duration_seconds = float(element.get("duration", 60 * 60 * 24)) - if duration_seconds <= 0: + # Use the pre-fetched sensor data + plot_data = element.get("_plot_data") + if not plot_data: raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="plot_duration_invalid", + translation_key="plot_no_prefetch_data", ) - duration = timedelta(seconds=duration_seconds) - end = dt.now() - start = end - duration + + all_states = plot_data["all_states"] + start = plot_data["start"] + end = plot_data["end"] + duration = plot_data["duration"] # Set up font font_name = element.get("font", "ppb.ttf") @@ -64,18 +109,6 @@ async def draw_plot(ctx: DrawingContext, element: dict) -> None: min_v = element.get("low") max_v = element.get("high") - # Fetch sensor data - all_states = await get_instance(ctx.hass).async_add_executor_job(partial(get_significant_states, - ctx.hass, - start_time=start, - entity_ids=[plot["entity"] for - plot in - element["data"]], - significant_changes_only=False, - minimal_response=True, - no_attributes=False - )) - # Process data and find min/max if not specified raw_data = [] for plot in element["data"]: @@ -744,7 +777,7 @@ def catmull_rom(p0, p1, p2, p3, t): @element_handler(ElementType.PROGRESS_BAR, requires=["x_start", "x_end", "y_start", "y_end", "progress"]) -async def draw_progress_bar(ctx: DrawingContext, element: dict) -> None: +def draw_progress_bar(ctx: DrawingContext, element: dict) -> None: """Draw progress bar with optional percentage text. Renders a progress bar to visualize a percentage value, with options @@ -851,7 +884,7 @@ async def draw_progress_bar(ctx: DrawingContext, element: dict) -> None: @element_handler(ElementType.DIAGRAM, requires=["x", "height"]) -async def draw_diagram(ctx: DrawingContext, element: dict) -> None: +def draw_diagram(ctx: DrawingContext, element: dict) -> None: """Draw diagram with optional bars. Renders a basic diagram with axes and optional bar chart elements. diff --git a/tests/drawcustom/conftest.py b/tests/drawcustom/conftest.py index cdeacc7..cbead19 100644 --- a/tests/drawcustom/conftest.py +++ b/tests/drawcustom/conftest.py @@ -23,8 +23,10 @@ def mock_hass(): """Create a mock Home Assistant instance.""" hass = MagicMock(spec=HomeAssistant) - # Mock async_add_executor_job - hass.async_add_executor_job = AsyncMock() + # Mock async_add_executor_job to actually call the function (so PIL rendering works in tests) + async def mock_executor_job(func, *args, **kwargs): + return func(*args, **kwargs) + hass.async_add_executor_job = mock_executor_job # Mock async_create_task to properly await coroutines async def mock_create_task(coro, name=None): diff --git a/tests/drawcustom/test_images/rename_me.jpg b/tests/drawcustom/test_images/rename_me.jpg new file mode 100644 index 0000000..9edf0d1 Binary files /dev/null and b/tests/drawcustom/test_images/rename_me.jpg differ