Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 111 additions & 44 deletions custom_components/opendisplay/imagegen/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -359,31 +367,27 @@ 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):
if not self.should_show_element(element):
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)
Expand All @@ -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
2 changes: 1 addition & 1 deletion custom_components/opendisplay/imagegen/debug.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
103 changes: 45 additions & 58 deletions custom_components/opendisplay/imagegen/icons.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
"""
Expand All @@ -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,
Expand All @@ -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(
Expand All @@ -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")
Expand Down Expand Up @@ -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.

Expand All @@ -151,28 +162,17 @@ 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,
translation_key="mdi_metadata_failed",
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
Expand All @@ -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}")
Expand Down
Loading