Skip to content
Merged
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
4 changes: 4 additions & 0 deletions src/odl_renderer/elements/visualizations.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,10 @@ def _process_entity_segments(
try:
value = float(state["state"]) * value_scale
timestamp = datetime.fromisoformat(state["last_changed"])
# Normalize naive timestamps to UTC so they can be subtracted from the
# timezone-aware plot range without raising TypeError.
if timestamp.tzinfo is None:
timestamp = timestamp.replace(tzinfo=timezone.utc)

should_break = False
if isinstance(span_gaps, (int, float)) and span_gaps is not True and span_gaps is not False:
Expand Down
21 changes: 19 additions & 2 deletions src/odl_renderer/media_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@

_LOGGER = logging.getLogger(__name__)

# Warn only once about creating a throwaway HTTP session, so a dashboard that
# re-renders every update doesn't repeat the message on every frame.
_warned_sessionless = False


async def load_image(
source: str | bytes | Image.Image,
Expand Down Expand Up @@ -97,14 +101,25 @@ async def _load_from_http(
response.raise_for_status()
image_bytes = await response.read()
else:
global _warned_sessionless
if not _warned_sessionless:
_warned_sessionless = True
_LOGGER.warning(
"load_image called without a session; creating a temporary aiohttp "
"ClientSession per request. Pass a shared session for connection "
"pooling and DNS caching."
)

import aiohttp

async with aiohttp.ClientSession() as temp_session:
async with temp_session.get(url) as response:
response.raise_for_status()
image_bytes = await response.read()

return Image.open(io.BytesIO(image_bytes))
img = Image.open(io.BytesIO(image_bytes))
img.load() # decode now, not later mid-composite
return img

except Exception as err:
raise ValueError(f"Failed to load image from {url}: {err}") from err
Expand All @@ -123,7 +138,9 @@ def _load_from_file(file_path: str) -> Image.Image:
ValueError: If the file doesn't exist or cannot be loaded
"""
try:
return Image.open(file_path)
img = Image.open(file_path)
img.load() # force decode now so the file handle is released (no ResourceWarning)
return img
except FileNotFoundError:
raise ValueError(f"Image file not found: {file_path}")
except Exception as err:
Expand Down
3 changes: 2 additions & 1 deletion src/odl_renderer/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ async def get_history(
Mapping of entity_id to a list of state records, ordered oldest-first.
Each record is a dict with at minimum:
- "state": str — the state value (e.g. "23.5", "on")
- "last_changed": str — ISO 8601 timestamp
- "last_changed": str — ISO 8601 timestamp. Prefer a timezone-aware
value (with offset); a naive timestamp is interpreted as UTC.
Entities with no data in the range may be absent or map to an empty list.
"""
...
Expand Down
8 changes: 7 additions & 1 deletion src/odl_renderer/warmup.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

from __future__ import annotations

import logging

_LOGGER = logging.getLogger(__name__)

_DEFAULT_SIZES = (16, 24, 32, 48, 64, 96)


Expand All @@ -19,4 +23,6 @@ def warmup(sizes: tuple[int, ...] = _DEFAULT_SIZES) -> None:
try:
_get_mdi_font(size)
except Exception: # noqa: BLE001
break
# One bad size shouldn't skip pre-warming the rest.
_LOGGER.warning("Failed to pre-warm MDI font at size %s", size, exc_info=True)
continue
30 changes: 28 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,41 @@
from io import BytesIO
from pathlib import Path

import imagehash
import pytest
from PIL import Image, ImageFont
from PIL import Image, ImageChops, ImageFont, UnidentifiedImageError
from syrupy.extensions.image import PNGImageSnapshotExtension

# Get package root
PACKAGE_ROOT = Path(__file__).parent.parent
ASSETS_DIR = PACKAGE_ROOT / "src" / "odl_renderer" / "assets"


class PixelPNGImageSnapshotExtension(PNGImageSnapshotExtension):
"""PNG snapshot extension that matches on decoded pixels, not encoded bytes.

The default image extension compares raw PNG bytes, so a Pillow/zlib upgrade
that re-encodes pixel-identical output into different bytes breaks every
snapshot (observed on main under Pillow 12). Decoding both sides and comparing
pixels keeps snapshots stable across encoder changes while still catching any
real visual difference.
"""

def matches(self, *, serialized_data: object, snapshot_data: object) -> bool:
try:
new_img = Image.open(BytesIO(bytes(serialized_data))) # type: ignore[arg-type]
old_img = Image.open(BytesIO(bytes(snapshot_data))) # type: ignore[arg-type]
except (UnidentifiedImageError, OSError, TypeError, ValueError):
# Not decodable as images — fall back to the byte comparison.
return bool(serialized_data == snapshot_data)
if new_img.size != old_img.size:
return False
diff = ImageChops.difference(new_img.convert("RGBA"), old_img.convert("RGBA"))
# alpha_only=False so a difference in any RGB channel counts — the default
# (alpha-only) would ignore color changes on fully-opaque e-paper renders.
return diff.getbbox(alpha_only=False) is None


@pytest.fixture
def ppb_font():
"""Provide path to bundled ppb font."""
Expand Down Expand Up @@ -50,4 +76,4 @@ def _compare(img1: Image.Image, img2: Image.Image, threshold: int = 5):

@pytest.fixture
def snapshot_png(snapshot):
return snapshot.use_extension(PNGImageSnapshotExtension)
return snapshot.use_extension(PixelPNGImageSnapshotExtension)
19 changes: 19 additions & 0 deletions tests/integration/test_plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,25 @@ async def test_plot_multiple_entities(self):
)
assert isinstance(image, Image.Image)

async def test_plot_naive_timestamps(self):
"""Naive (tz-less) last_changed values are treated as UTC, not crash (A12)."""
end = datetime.now(timezone.utc)
start = end - timedelta(hours=24)
step = timedelta(hours=2)
naive_states = [
# isoformat() on a naive datetime has no offset suffix.
{"state": str(20 + i), "last_changed": (start + step * i).replace(tzinfo=None).isoformat()}
for i in range(12)
]
provider = MockDataProvider({"sensor.temp": naive_states})
image = await generate_image(
width=296,
height=128,
elements=[{"type": "plot", "data": [{"entity": "sensor.temp"}]}],
data_provider=provider,
)
assert isinstance(image, Image.Image)

async def test_plot_span_gaps_numeric(self):
"""Time-based gap detection (span_gaps as seconds) doesn't crash."""
gapped = TEMP_STATES[:8] + TEMP_STATES[16:] # introduce a gap in the middle
Expand Down
12 changes: 12 additions & 0 deletions tests/unit/test_media_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,18 @@ async def test_file_path(self):
result = await load_image(path)
assert isinstance(result, Image.Image)

async def test_file_path_releases_handle(self):
"""The file is decoded eagerly and its handle released (finding A12)."""
png_bytes = _make_png_bytes()
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
f.write(png_bytes)
path = f.name
result = await load_image(path)
# After .load(), Pillow drops the file pointer it owned.
assert getattr(result, "fp", None) is None
# The image is usable without touching the file again.
assert result.convert("RGBA").size == result.size

async def test_nonexistent_file_raises(self):
with pytest.raises(ValueError, match="not found"):
await load_image("/nonexistent/path/image.png")
Expand Down
46 changes: 46 additions & 0 deletions tests/unit/test_snapshot_extension.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Unit tests for the pixel-comparing PNG snapshot extension (finding C1)."""

from io import BytesIO

from PIL import Image

from tests.conftest import PixelPNGImageSnapshotExtension


def _png(img: Image.Image, **save_kwargs) -> bytes:
buffer = BytesIO()
img.save(buffer, format="PNG", **save_kwargs)
return buffer.getvalue()


def _matches(a: bytes, b: bytes) -> bool:
# matches() only needs `self` for the (unused here) byte fallback.
ext = object.__new__(PixelPNGImageSnapshotExtension)
return ext.matches(serialized_data=a, snapshot_data=b)


def test_same_pixels_different_bytes_match():
"""Different PNG encodings of identical pixels must compare equal."""
img = Image.new("RGBA", (20, 12), (10, 20, 30, 255))
img.putpixel((5, 5), (200, 100, 50, 255))
a = _png(img, compress_level=0)
b = _png(img, compress_level=9)
assert a != b # the encoders really did produce different bytes
assert _matches(a, b) is True


def test_different_pixels_do_not_match():
a = _png(Image.new("RGBA", (20, 12), (0, 0, 0, 255)))
b = _png(Image.new("RGBA", (20, 12), (255, 255, 255, 255)))
assert _matches(a, b) is False


def test_different_size_does_not_match():
a = _png(Image.new("RGBA", (20, 12), (0, 0, 0, 255)))
b = _png(Image.new("RGBA", (24, 12), (0, 0, 0, 255)))
assert _matches(a, b) is False


def test_non_image_falls_back_to_bytes():
assert _matches(b"not-a-png", b"not-a-png") is True
assert _matches(b"not-a-png", b"other") is False
21 changes: 21 additions & 0 deletions tests/unit/test_warmup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""Unit tests for warmup (finding A12: one bad size must not skip the rest)."""

from odl_renderer.warmup import warmup


def test_warmup_continues_past_a_failing_size(monkeypatch):
attempted = []

def fake_get_mdi_font(size):
attempted.append(size)
if size == 24:
raise OSError("simulated font load failure")
return object()

# warmup imports _get_mdi_font from elements.icons inside the function.
monkeypatch.setattr("odl_renderer.elements.icons._get_mdi_font", fake_get_mdi_font)

warmup(sizes=(16, 24, 32))

# All sizes attempted despite 24 failing — no early break.
assert attempted == [16, 24, 32]
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading