mirror of
https://github.com/esphome/esphome.git
synced 2026-06-24 11:25:35 +00:00
[lvgl][mipi_spi][mipi_rgb][mipi_dsi][display] Metadata (#16702)
This commit is contained in:
@@ -3,11 +3,18 @@ from dataclasses import dataclass
|
||||
from esphome import automation, core
|
||||
from esphome.automation import maybe_simple_id
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import KEY_METADATA
|
||||
from esphome.components.const import (
|
||||
BYTE_ORDER_BIG,
|
||||
CONF_BYTE_ORDER,
|
||||
CONF_DRAW_ROUNDING,
|
||||
KEY_METADATA,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_AUTO_CLEAR_ENABLED,
|
||||
CONF_DIMENSIONS,
|
||||
CONF_FROM,
|
||||
CONF_HEIGHT,
|
||||
CONF_ID,
|
||||
CONF_LAMBDA,
|
||||
CONF_PAGE_ID,
|
||||
@@ -16,10 +23,11 @@ from esphome.const import (
|
||||
CONF_TO,
|
||||
CONF_TRIGGER_ID,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
CONF_WIDTH,
|
||||
SCHEDULER_DONT_RUN,
|
||||
)
|
||||
from esphome.core import CORE, CoroPriority, coroutine_with_priority
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
|
||||
from esphome.final_validate import full_config
|
||||
|
||||
DOMAIN = "display"
|
||||
IS_PLATFORM_COMPONENT = True
|
||||
@@ -159,29 +167,97 @@ async def setup_display_core_(var, config):
|
||||
class DisplayMetaData:
|
||||
width: int = 0
|
||||
height: int = 0
|
||||
has_writer: bool = False
|
||||
has_hardware_rotation: bool = False
|
||||
byte_order: str = BYTE_ORDER_BIG
|
||||
has_writer: bool = False
|
||||
rotation: int = 0
|
||||
draw_rounding: int = 0
|
||||
|
||||
|
||||
def _get_metadata_list() -> list[tuple]:
|
||||
"""Get the raw metadata list. Each entry is (id, DisplayMetaData)."""
|
||||
return CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_METADATA, [])
|
||||
|
||||
|
||||
def get_all_display_metadata() -> dict[str, DisplayMetaData]:
|
||||
"""Get all display metadata."""
|
||||
return CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_METADATA, {})
|
||||
"""Get all display metadata as a dict keyed by resolved ID strings.
|
||||
|
||||
Must not be called before IDs have been finalised.
|
||||
"""
|
||||
entries = _get_metadata_list()
|
||||
assert all(id_.id is not None for id_, _ in entries), (
|
||||
"get_all_display_metadata called before display IDs have been resolved"
|
||||
)
|
||||
return {id_.id: meta for id_, meta in entries}
|
||||
|
||||
|
||||
def get_display_metadata(display_id: str) -> DisplayMetaData | None:
|
||||
"""Get display metadata by ID for use by other components."""
|
||||
return get_all_display_metadata().get(display_id, DisplayMetaData())
|
||||
def get_display_metadata(display_id: ID) -> DisplayMetaData:
|
||||
"""Get display metadata by ID object
|
||||
|
||||
Must not be called before IDs have been finalised.
|
||||
"""
|
||||
for id_, meta in _get_metadata_list():
|
||||
if id_ is display_id:
|
||||
return meta
|
||||
assert id_.id is not None, (
|
||||
"get_display_metadata called before display IDs have been resolved"
|
||||
)
|
||||
if id_.id == display_id.id:
|
||||
return meta
|
||||
# No metadata found, display driver may not yet support it.
|
||||
# Read the raw config to populate the returned data
|
||||
global_config = full_config.get()
|
||||
path = global_config.get_path_for_id(display_id)[:-1]
|
||||
disp_config = global_config.get_config_for_path(path)
|
||||
dimensions = disp_config.get(CONF_DIMENSIONS, (0, 0))
|
||||
if isinstance(dimensions, dict):
|
||||
dimensions = (dimensions.get(CONF_WIDTH, 0), dimensions.get(CONF_HEIGHT, 0))
|
||||
elif not isinstance(dimensions, tuple) or len(dimensions) != 2:
|
||||
dimensions = (0, 0)
|
||||
|
||||
meta = DisplayMetaData(
|
||||
width=dimensions[0],
|
||||
height=dimensions[1],
|
||||
has_hardware_rotation=False,
|
||||
byte_order=disp_config.get(CONF_BYTE_ORDER, cv.UNDEFINED),
|
||||
has_writer=disp_config.get(CONF_AUTO_CLEAR_ENABLED) is True
|
||||
or disp_config.get(CONF_PAGES) is not None
|
||||
or disp_config.get(CONF_LAMBDA) is not None
|
||||
or disp_config.get(CONF_SHOW_TEST_CARD) is True,
|
||||
rotation=disp_config.get(CONF_ROTATION, 0),
|
||||
draw_rounding=disp_config.get(CONF_DRAW_ROUNDING, 0),
|
||||
)
|
||||
_get_metadata_list().append((display_id, meta))
|
||||
return meta
|
||||
|
||||
|
||||
def add_metadata(
|
||||
id: str | MockObj,
|
||||
id: ID,
|
||||
width: int,
|
||||
height: int,
|
||||
has_writer: bool,
|
||||
has_hardware_rotation: bool = False,
|
||||
byte_order: str = BYTE_ORDER_BIG,
|
||||
has_writer: bool = False,
|
||||
rotation: int = 0,
|
||||
draw_rounding: int = 0,
|
||||
):
|
||||
get_all_display_metadata()[str(id)] = DisplayMetaData(
|
||||
width, height, has_writer, has_hardware_rotation
|
||||
entries = _get_metadata_list()
|
||||
assert not any(existing_id is id for existing_id, _ in entries), (
|
||||
f"Duplicate display metadata for ID {id}"
|
||||
)
|
||||
entries.append(
|
||||
(
|
||||
id,
|
||||
DisplayMetaData(
|
||||
width=width,
|
||||
height=height,
|
||||
has_hardware_rotation=has_hardware_rotation,
|
||||
byte_order=byte_order,
|
||||
has_writer=has_writer,
|
||||
rotation=rotation,
|
||||
draw_rounding=draw_rounding,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import re
|
||||
from esphome.automation import Trigger, build_automation, validate_automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import (
|
||||
BYTE_ORDER_BIG,
|
||||
CONF_BYTE_ORDER,
|
||||
CONF_COLOR_DEPTH,
|
||||
CONF_DRAW_ROUNDING,
|
||||
@@ -30,12 +31,10 @@ from esphome.components.image import (
|
||||
from esphome.components.psram import DOMAIN as PSRAM_DOMAIN
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_AUTO_CLEAR_ENABLED,
|
||||
CONF_BUFFER_SIZE,
|
||||
CONF_ESPHOME,
|
||||
CONF_GROUP,
|
||||
CONF_ID,
|
||||
CONF_LAMBDA,
|
||||
CONF_LOG_LEVEL,
|
||||
CONF_ON_IDLE,
|
||||
CONF_PAGES,
|
||||
@@ -214,61 +213,73 @@ def multi_conf_validate(configs: list[dict]):
|
||||
|
||||
|
||||
def final_validation(config_list):
|
||||
if len(config_list) != 1:
|
||||
multi_conf_validate(config_list)
|
||||
global_config = full_config.get()
|
||||
# Resolve byte_order from display metadata before multi-config validation
|
||||
for config in config_list:
|
||||
metas = [get_display_metadata(disp) for disp in config[df.CONF_DISPLAYS]]
|
||||
if any(m.has_writer for m in metas):
|
||||
raise cv.Invalid(
|
||||
"Using lambda:, pages:, auto_clear_enabled: true, or show_test_card: true in display config is not compatible with LVGL"
|
||||
)
|
||||
if any(m.rotation != 0 for m in metas):
|
||||
raise cv.Invalid(
|
||||
"use of 'rotation' in the display config is not compatible with LVGL, please set rotation in the LVGL config instead"
|
||||
)
|
||||
config[CONF_DRAW_ROUNDING] = max(
|
||||
[m.draw_rounding for m in metas] + [config[CONF_DRAW_ROUNDING]]
|
||||
)
|
||||
display_byte_orders = {
|
||||
m.byte_order for m in metas if m.byte_order is not cv.UNDEFINED
|
||||
}
|
||||
if len(display_byte_orders) > 1:
|
||||
raise cv.Invalid(
|
||||
"All displays configured for an LVGL instance must use the same byte_order"
|
||||
)
|
||||
if display_byte_orders:
|
||||
display_order = next(iter(display_byte_orders))
|
||||
if CONF_BYTE_ORDER in config:
|
||||
if config[CONF_BYTE_ORDER] != display_order:
|
||||
raise cv.Invalid(
|
||||
"LVGL byte order must match the display byte order",
|
||||
[CONF_BYTE_ORDER],
|
||||
)
|
||||
else:
|
||||
config[CONF_BYTE_ORDER] = display_order
|
||||
if CONF_BYTE_ORDER not in config:
|
||||
config[CONF_BYTE_ORDER] = BYTE_ORDER_BIG
|
||||
|
||||
if (pages := config.get(CONF_PAGES)) and all(p[df.CONF_SKIP] for p in pages):
|
||||
raise cv.Invalid("At least one page must not be skipped")
|
||||
for display_id in config[df.CONF_DISPLAYS]:
|
||||
path = global_config.get_path_for_id(display_id)[:-1]
|
||||
display = global_config.get_config_for_path(path)
|
||||
if CONF_LAMBDA in display or CONF_PAGES in display:
|
||||
raise cv.Invalid(
|
||||
"Using lambda: or pages: in display config is not compatible with LVGL"
|
||||
)
|
||||
# treating 0 as false is intended here.
|
||||
if display.get(CONF_ROTATION):
|
||||
raise cv.Invalid(
|
||||
"use of 'rotation' in the display config is not compatible with LVGL, please set rotation in the LVGL config instead"
|
||||
)
|
||||
if display.get(CONF_AUTO_CLEAR_ENABLED) is True:
|
||||
raise cv.Invalid(
|
||||
"Using auto_clear_enabled: true in display config not compatible with LVGL"
|
||||
)
|
||||
if draw_rounding := display.get(CONF_DRAW_ROUNDING):
|
||||
config[CONF_DRAW_ROUNDING] = max(
|
||||
draw_rounding, config[CONF_DRAW_ROUNDING]
|
||||
)
|
||||
buffer_frac = config[CONF_BUFFER_SIZE]
|
||||
if CORE.is_esp32 and buffer_frac > 0.5 and PSRAM_DOMAIN not in global_config:
|
||||
df.LOGGER.warning("buffer_size: may need to be reduced without PSRAM")
|
||||
for w in get_focused_widgets():
|
||||
path = global_config.get_path_for_id(w)
|
||||
widget_conf = global_config.get_config_for_path(path[:-1])
|
||||
if (
|
||||
df.CONF_ADJUSTABLE in widget_conf
|
||||
and not widget_conf[df.CONF_ADJUSTABLE]
|
||||
):
|
||||
raise cv.Invalid(
|
||||
"A non adjustable arc may not be focused",
|
||||
path,
|
||||
)
|
||||
for w in get_refreshed_widgets():
|
||||
path = global_config.get_path_for_id(w)
|
||||
widget_conf = global_config.get_config_for_path(path[:-1])
|
||||
if not any(isinstance(v, (Lambda, dict)) for v in widget_conf.values()):
|
||||
raise cv.Invalid(
|
||||
f"Widget '{w}' does not have any dynamic properties to refresh",
|
||||
)
|
||||
# Do per-widget type final validation for update actions
|
||||
for widget_type, update_configs in df.get_updated_widgets().items():
|
||||
for conf in update_configs:
|
||||
for id_conf in conf.get(CONF_ID, ()):
|
||||
name = id_conf[CONF_ID]
|
||||
path = global_config.get_path_for_id(name)
|
||||
widget_conf = global_config.get_config_for_path(path[:-1])
|
||||
widget_type.final_validate(name, conf, widget_conf, path[1:])
|
||||
|
||||
if len(config_list) != 1:
|
||||
multi_conf_validate(config_list)
|
||||
|
||||
for w in get_focused_widgets():
|
||||
path = global_config.get_path_for_id(w)
|
||||
widget_conf = global_config.get_config_for_path(path[:-1])
|
||||
if df.CONF_ADJUSTABLE in widget_conf and not widget_conf[df.CONF_ADJUSTABLE]:
|
||||
raise cv.Invalid(
|
||||
"A non adjustable arc may not be focused",
|
||||
path,
|
||||
)
|
||||
for w in get_refreshed_widgets():
|
||||
path = global_config.get_path_for_id(w)
|
||||
widget_conf = global_config.get_config_for_path(path[:-1])
|
||||
if not any(isinstance(v, (Lambda, dict)) for v in widget_conf.values()):
|
||||
raise cv.Invalid(
|
||||
f"Widget '{w}' does not have any dynamic properties to refresh",
|
||||
)
|
||||
# Do per-widget type final validation for update actions
|
||||
for widget_type, update_configs in df.get_updated_widgets().items():
|
||||
for conf in update_configs:
|
||||
for id_conf in conf.get(CONF_ID, ()):
|
||||
name = id_conf[CONF_ID]
|
||||
path = global_config.get_path_for_id(name)
|
||||
widget_conf = global_config.get_config_for_path(path[:-1])
|
||||
widget_type.final_validate(name, conf, widget_conf, path[1:])
|
||||
|
||||
|
||||
async def to_code(configs):
|
||||
@@ -367,8 +378,7 @@ async def to_code(configs):
|
||||
# options will have CONF_ROTATION true if rotation is changed in an automation.
|
||||
if CONF_ROTATION in config or df.get_options().get(CONF_ROTATION) is True:
|
||||
if all(
|
||||
get_display_metadata(str(disp)).has_hardware_rotation
|
||||
for disp in displays
|
||||
get_display_metadata(disp).has_hardware_rotation for disp in displays
|
||||
):
|
||||
rotation_type = RotationType.ROTATION_HARDWARE
|
||||
df.LOGGER.info("LVGL will use hardware rotation via display driver")
|
||||
@@ -583,7 +593,7 @@ LVGL_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of(
|
||||
*df.LV_LOG_LEVELS, upper=True
|
||||
),
|
||||
cv.Optional(CONF_BYTE_ORDER, default="big_endian"): cv.one_of(
|
||||
cv.Optional(CONF_BYTE_ORDER): cv.one_of(
|
||||
"big_endian", "little_endian", lower=True
|
||||
),
|
||||
cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list(
|
||||
|
||||
@@ -37,6 +37,7 @@ from esphome.components.mipi import (
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_AUTO_CLEAR_ENABLED,
|
||||
CONF_COLOR_ORDER,
|
||||
CONF_DIMENSIONS,
|
||||
CONF_DISABLED,
|
||||
@@ -167,7 +168,21 @@ def _config_schema(config):
|
||||
},
|
||||
extra=cv.ALLOW_EXTRA,
|
||||
)(config)
|
||||
return model_schema(config)(config)
|
||||
config = model_schema(config)(config)
|
||||
model = MODELS[config[CONF_MODEL].upper()]
|
||||
width, height, _offset_width, _offset_height = model.get_dimensions(config)
|
||||
display.add_metadata(
|
||||
config[CONF_ID],
|
||||
width,
|
||||
height,
|
||||
has_hardware_rotation=False,
|
||||
byte_order=config[CONF_BYTE_ORDER],
|
||||
has_writer=requires_buffer(config)
|
||||
or config.get(CONF_AUTO_CLEAR_ENABLED) is True,
|
||||
rotation=config.get(CONF_ROTATION, 0),
|
||||
draw_rounding=config.get(CONF_DRAW_ROUNDING, 0),
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def _final_validate(config):
|
||||
|
||||
@@ -39,6 +39,7 @@ from esphome.components.rpi_dpi_rgb.display import (
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_AUTO_CLEAR_ENABLED,
|
||||
CONF_BLUE,
|
||||
CONF_COLOR_ORDER,
|
||||
CONF_CS_PIN,
|
||||
@@ -226,11 +227,25 @@ def _config_schema(config):
|
||||
extra=cv.ALLOW_EXTRA,
|
||||
)(config)
|
||||
schema = model_schema(config)
|
||||
return cv.All(
|
||||
config = cv.All(
|
||||
schema,
|
||||
cv.only_on_esp32,
|
||||
only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4]),
|
||||
)(config)
|
||||
model = MODELS[config[CONF_MODEL].upper()]
|
||||
width, height, _offset_width, _offset_height = model.get_dimensions(config)
|
||||
display.add_metadata(
|
||||
config[CONF_ID],
|
||||
width,
|
||||
height,
|
||||
model.rotation_as_transform(config),
|
||||
byte_order=config[CONF_BYTE_ORDER],
|
||||
has_writer=requires_buffer(config)
|
||||
or config.get(CONF_AUTO_CLEAR_ENABLED) is True,
|
||||
rotation=config.get(CONF_ROTATION, 0),
|
||||
draw_rounding=config.get(CONF_DRAW_ROUNDING, 0),
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = _config_schema
|
||||
|
||||
@@ -30,6 +30,7 @@ from esphome.components.spi import TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE
|
||||
import esphome.config_validation as cv
|
||||
from esphome.config_validation import ALLOW_EXTRA
|
||||
from esphome.const import (
|
||||
CONF_AUTO_CLEAR_ENABLED,
|
||||
CONF_BRIGHTNESS,
|
||||
CONF_BUFFER_SIZE,
|
||||
CONF_COLOR_ORDER,
|
||||
@@ -47,6 +48,7 @@ from esphome.const import (
|
||||
CONF_MIRROR_Y,
|
||||
CONF_MODEL,
|
||||
CONF_RESET_PIN,
|
||||
CONF_ROTATION,
|
||||
CONF_SWAP_XY,
|
||||
CONF_TRANSFORM,
|
||||
CONF_WIDTH,
|
||||
@@ -267,6 +269,28 @@ def customise_schema(config):
|
||||
if bus_mode != TYPE_QUAD and CONF_DC_PIN not in config:
|
||||
raise cv.Invalid(f"DC pin is required in {bus_mode} mode")
|
||||
denominator(config)
|
||||
model = MODELS[config[CONF_MODEL]]
|
||||
has_hardware_transform = config.get(
|
||||
CONF_TRANSFORM
|
||||
) != CONF_DISABLED and model.transforms == {
|
||||
CONF_MIRROR_X,
|
||||
CONF_MIRROR_Y,
|
||||
CONF_SWAP_XY,
|
||||
}
|
||||
width, height, _offset_width, _offset_height = model.get_dimensions(
|
||||
config, not has_hardware_transform
|
||||
)
|
||||
display.add_metadata(
|
||||
config[CONF_ID],
|
||||
width,
|
||||
height,
|
||||
has_hardware_transform,
|
||||
byte_order=config[CONF_BYTE_ORDER],
|
||||
has_writer=requires_buffer(config)
|
||||
or config.get(CONF_AUTO_CLEAR_ENABLED) is True,
|
||||
rotation=config.get(CONF_ROTATION, 0),
|
||||
draw_rounding=config.get(CONF_DRAW_ROUNDING, 0),
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
@@ -338,7 +362,6 @@ def get_instance(config):
|
||||
buffer_type = cg.uint8 if color_depth == 8 else cg.uint16
|
||||
frac = denominator(config)
|
||||
madctl = model.get_madctl(model.get_base_transform(config), config)
|
||||
has_writer = requires_buffer(config)
|
||||
templateargs = [
|
||||
buffer_type,
|
||||
bufferpixels,
|
||||
@@ -352,9 +375,6 @@ def get_instance(config):
|
||||
madctl,
|
||||
has_hardware_transform,
|
||||
]
|
||||
display.add_metadata(
|
||||
config[CONF_ID], width, height, has_writer, has_hardware_transform
|
||||
)
|
||||
# If a buffer is required, use MipiSpiBuffer, otherwise use MipiSpi
|
||||
if requires_buffer(config):
|
||||
templateargs.extend(
|
||||
|
||||
@@ -4,77 +4,145 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.const import BYTE_ORDER_BIG, BYTE_ORDER_LITTLE
|
||||
from esphome.components.display import (
|
||||
DisplayMetaData,
|
||||
add_metadata,
|
||||
get_all_display_metadata,
|
||||
get_display_metadata,
|
||||
)
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.config import Config
|
||||
from esphome.core import ID
|
||||
from esphome.final_validate import full_config
|
||||
|
||||
|
||||
def test_add_metadata_with_string_id():
|
||||
"""Test adding metadata with a plain string ID."""
|
||||
def test_add_metadata_basic():
|
||||
"""Test adding metadata with an ID object."""
|
||||
with patch("esphome.components.display.CORE.data", {}):
|
||||
add_metadata("my_display", 320, 240, True)
|
||||
meta = get_display_metadata("my_display")
|
||||
add_metadata(ID("my_display"), 320, 240)
|
||||
meta = get_display_metadata(ID("my_display"))
|
||||
assert meta == DisplayMetaData(
|
||||
width=320, height=240, has_writer=True, has_hardware_rotation=False
|
||||
width=320,
|
||||
height=240,
|
||||
has_hardware_rotation=False,
|
||||
byte_order=BYTE_ORDER_BIG,
|
||||
)
|
||||
|
||||
|
||||
def test_add_metadata_with_mockobj_id():
|
||||
"""Test adding metadata with a MockObj ID (converted via str())."""
|
||||
def test_add_metadata_with_all_fields():
|
||||
"""Test adding metadata with all fields set."""
|
||||
with patch("esphome.components.display.CORE.data", {}):
|
||||
mock_id = MockObj("my_display_obj")
|
||||
add_metadata(mock_id, 480, 320, False, has_hardware_rotation=True)
|
||||
meta = get_display_metadata("my_display_obj")
|
||||
add_metadata(
|
||||
ID("my_display"),
|
||||
480,
|
||||
320,
|
||||
has_hardware_rotation=True,
|
||||
byte_order=BYTE_ORDER_LITTLE,
|
||||
)
|
||||
meta = get_display_metadata(ID("my_display"))
|
||||
assert meta == DisplayMetaData(
|
||||
width=480, height=320, has_writer=False, has_hardware_rotation=True
|
||||
width=480,
|
||||
height=320,
|
||||
has_hardware_rotation=True,
|
||||
byte_order=BYTE_ORDER_LITTLE,
|
||||
)
|
||||
|
||||
|
||||
def test_add_metadata_hardware_rotation_default():
|
||||
"""Test that has_hardware_rotation defaults to False."""
|
||||
with patch("esphome.components.display.CORE.data", {}):
|
||||
add_metadata("disp", 128, 64, False)
|
||||
meta = get_display_metadata("disp")
|
||||
add_metadata(ID("disp"), 128, 64)
|
||||
meta = get_display_metadata(ID("disp"))
|
||||
assert meta.has_hardware_rotation is False
|
||||
assert meta.byte_order == BYTE_ORDER_BIG
|
||||
|
||||
|
||||
def test_get_display_metadata_missing_returns_none():
|
||||
"""Test that querying a non-existent ID returns None."""
|
||||
def test_add_metadata_with_byte_order():
|
||||
"""Test adding metadata with explicit byte_order."""
|
||||
with patch("esphome.components.display.CORE.data", {}):
|
||||
data = get_display_metadata("no_such_display")
|
||||
assert data.width == 0
|
||||
assert data.height == 0
|
||||
assert data.has_writer is False
|
||||
add_metadata(ID("disp"), 240, 320, byte_order=BYTE_ORDER_LITTLE)
|
||||
meta = get_display_metadata(ID("disp"))
|
||||
assert meta.byte_order == BYTE_ORDER_LITTLE
|
||||
|
||||
|
||||
def test_get_display_metadata_missing_reads_raw_config():
|
||||
"""Querying a non-existent ID falls back to raw config lookup."""
|
||||
with patch("esphome.components.display.CORE.data", {}):
|
||||
# Set up a minimal full_config with a display entry so the fallback
|
||||
# path in get_display_metadata can find the display config.
|
||||
fc = Config()
|
||||
fc["display"] = [
|
||||
{
|
||||
"id": ID("no_such_display", True),
|
||||
"auto_clear_enabled": True,
|
||||
"dimensions": {"width": 320, "height": 240},
|
||||
"byte_order": BYTE_ORDER_LITTLE,
|
||||
"rotation": 90,
|
||||
},
|
||||
{
|
||||
"id": ID("other_display", True),
|
||||
"auto_clear_enabled": "undefined",
|
||||
"dimensions": (1024, 600),
|
||||
},
|
||||
]
|
||||
fc.declare_ids.append((ID("no_such_display", True), ["display", 0, "id"]))
|
||||
fc.declare_ids.append((ID("other_display", True), ["display", 1, "id"]))
|
||||
full_config.set(fc)
|
||||
data = get_display_metadata(ID("no_such_display"))
|
||||
assert data.width == 320
|
||||
assert data.height == 240
|
||||
assert data.has_hardware_rotation is False
|
||||
assert data.has_writer is True
|
||||
assert data.byte_order == BYTE_ORDER_LITTLE
|
||||
assert data.rotation == 90
|
||||
|
||||
data = get_display_metadata(ID("other_display"))
|
||||
assert data.width == 1024
|
||||
assert data.height == 600
|
||||
assert data.has_writer is False
|
||||
|
||||
|
||||
def test_add_multiple_displays():
|
||||
"""Test adding metadata for multiple displays."""
|
||||
with patch("esphome.components.display.CORE.data", {}):
|
||||
add_metadata("disp_a", 320, 240, True)
|
||||
add_metadata("disp_b", 128, 64, False, has_hardware_rotation=True)
|
||||
add_metadata(ID("disp_a"), 320, 240)
|
||||
add_metadata(ID("disp_b"), 128, 64, has_hardware_rotation=True)
|
||||
|
||||
all_meta = get_all_display_metadata()
|
||||
assert len(all_meta) == 2
|
||||
assert all_meta["disp_a"] == DisplayMetaData(320, 240, True, False)
|
||||
assert all_meta["disp_b"] == DisplayMetaData(128, 64, False, True)
|
||||
assert all_meta["disp_a"] == DisplayMetaData(320, 240, False)
|
||||
assert all_meta["disp_b"] == DisplayMetaData(128, 64, True, BYTE_ORDER_BIG)
|
||||
|
||||
|
||||
def test_add_metadata_overwrites_existing():
|
||||
"""Test that adding metadata for the same ID overwrites the previous entry."""
|
||||
def test_add_duplicate_id_asserts():
|
||||
"""Adding metadata for the same ID object twice should assert."""
|
||||
with patch("esphome.components.display.CORE.data", {}):
|
||||
add_metadata("disp", 320, 240, True)
|
||||
add_metadata("disp", 640, 480, False, has_hardware_rotation=True)
|
||||
meta = get_display_metadata("disp")
|
||||
assert meta == DisplayMetaData(640, 480, False, True)
|
||||
id_obj = ID("disp")
|
||||
add_metadata(id_obj, 320, 240)
|
||||
with pytest.raises(AssertionError, match="Duplicate"):
|
||||
add_metadata(id_obj, 640, 480)
|
||||
|
||||
|
||||
def test_metadata_is_frozen():
|
||||
"""Test that DisplayMetaData instances are immutable (frozen dataclass)."""
|
||||
meta = DisplayMetaData(320, 240, True, False)
|
||||
meta = DisplayMetaData(320, 240, False, BYTE_ORDER_BIG)
|
||||
with pytest.raises(AttributeError):
|
||||
meta.width = 640
|
||||
with pytest.raises(AttributeError):
|
||||
meta.byte_order = BYTE_ORDER_LITTLE
|
||||
|
||||
|
||||
def test_get_all_metadata_asserts_on_unresolved_id():
|
||||
"""get_all_display_metadata should assert if any ID has id=None."""
|
||||
with patch("esphome.components.display.CORE.data", {}):
|
||||
add_metadata(ID(None), 320, 240)
|
||||
with pytest.raises(AssertionError, match="resolved"):
|
||||
get_all_display_metadata()
|
||||
|
||||
|
||||
def test_get_metadata_asserts_on_unresolved_id():
|
||||
"""get_display_metadata should assert if any ID has id=None."""
|
||||
with patch("esphome.components.display.CORE.data", {}):
|
||||
add_metadata(ID(None), 320, 240)
|
||||
with pytest.raises(AssertionError, match="resolved"):
|
||||
get_display_metadata(ID("anything"))
|
||||
|
||||
177
tests/component_tests/lvgl/test_validation.py
Normal file
177
tests/component_tests/lvgl/test_validation.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""Tests for LVGL final_validation display metadata checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.const import BYTE_ORDER_BIG, BYTE_ORDER_LITTLE, CONF_BYTE_ORDER
|
||||
from esphome.components.display import add_metadata
|
||||
from esphome.components.lvgl import final_validation
|
||||
from esphome.config import Config
|
||||
from esphome.config_validation import Invalid
|
||||
from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM
|
||||
from esphome.core import CORE, ID
|
||||
from esphome.final_validate import full_config
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_core():
|
||||
"""Ensure CORE.data has enough context for final_validation."""
|
||||
CORE.data[KEY_CORE] = {
|
||||
KEY_TARGET_PLATFORM: "host",
|
||||
KEY_TARGET_FRAMEWORK: "",
|
||||
}
|
||||
full_config.set(Config())
|
||||
yield
|
||||
CORE.reset()
|
||||
|
||||
|
||||
def _register_displays(*display_ids: str) -> None:
|
||||
"""Register display IDs in full_config so get_path_for_id works."""
|
||||
fc = full_config.get()
|
||||
display_list = [{"id": ID(d, True)} for d in display_ids]
|
||||
fc["display"] = display_list
|
||||
for i, disp_id in enumerate(display_ids):
|
||||
fc.declare_ids.append((ID(disp_id, True), ["display", i, "id"]))
|
||||
|
||||
|
||||
def _make_lvgl_config(
|
||||
display_ids: list[str],
|
||||
byte_order: str | None = None,
|
||||
) -> dict:
|
||||
"""Build a minimal LVGL config dict for final_validation."""
|
||||
_register_displays(*display_ids)
|
||||
config = {
|
||||
"displays": [ID(d, True) for d in display_ids],
|
||||
"log_level": "WARN",
|
||||
"color_depth": 16,
|
||||
"transparency_key": 0x000400,
|
||||
"draw_rounding": 2,
|
||||
"buffer_size": 0,
|
||||
}
|
||||
if byte_order is not None:
|
||||
config[CONF_BYTE_ORDER] = byte_order
|
||||
return config
|
||||
|
||||
|
||||
class TestByteOrderAutoConfig:
|
||||
"""Test that LVGL auto-configures byte_order from display metadata."""
|
||||
|
||||
def test_inherits_big_endian_from_display(self) -> None:
|
||||
"""LVGL should inherit big_endian from display metadata."""
|
||||
add_metadata(ID("my_disp"), 320, 240, byte_order=BYTE_ORDER_BIG)
|
||||
configs = [_make_lvgl_config(["my_disp"])]
|
||||
final_validation(configs)
|
||||
assert configs[0][CONF_BYTE_ORDER] == BYTE_ORDER_BIG
|
||||
|
||||
def test_inherits_little_endian_from_display(self) -> None:
|
||||
"""LVGL should inherit little_endian from display metadata."""
|
||||
add_metadata(ID("my_disp"), 320, 240, byte_order=BYTE_ORDER_LITTLE)
|
||||
configs = [_make_lvgl_config(["my_disp"])]
|
||||
final_validation(configs)
|
||||
assert configs[0][CONF_BYTE_ORDER] == BYTE_ORDER_LITTLE
|
||||
|
||||
def test_defaults_to_big_endian_when_no_metadata(self) -> None:
|
||||
"""LVGL should default to big_endian when display has no metadata."""
|
||||
configs = [_make_lvgl_config(["my_disp"])]
|
||||
final_validation(configs)
|
||||
assert configs[0][CONF_BYTE_ORDER] == BYTE_ORDER_BIG
|
||||
|
||||
|
||||
class TestByteOrderExplicitMismatchError:
|
||||
"""Test that LVGL rejects explicit byte_order mismatch with display."""
|
||||
|
||||
def test_raises_on_mismatch(self) -> None:
|
||||
"""Explicit LVGL byte_order different from display should raise."""
|
||||
add_metadata(ID("my_disp"), 320, 240, byte_order=BYTE_ORDER_LITTLE)
|
||||
configs = [_make_lvgl_config(["my_disp"], byte_order=BYTE_ORDER_BIG)]
|
||||
with pytest.raises(
|
||||
Invalid, match="LVGL byte order must match the display byte order"
|
||||
):
|
||||
final_validation(configs)
|
||||
|
||||
def test_no_error_when_matching(self) -> None:
|
||||
"""Explicit LVGL byte_order matching display should pass."""
|
||||
add_metadata(ID("my_disp"), 320, 240, byte_order=BYTE_ORDER_BIG)
|
||||
configs = [_make_lvgl_config(["my_disp"], byte_order=BYTE_ORDER_BIG)]
|
||||
final_validation(configs)
|
||||
|
||||
|
||||
class TestByteOrderMultipleDisplays:
|
||||
"""Test byte_order validation with multiple displays."""
|
||||
|
||||
def test_consistent_displays_inherit(self) -> None:
|
||||
"""All displays with same byte_order should set LVGL byte_order."""
|
||||
add_metadata(ID("disp_a"), 320, 240, byte_order=BYTE_ORDER_LITTLE)
|
||||
add_metadata(ID("disp_b"), 128, 64, byte_order=BYTE_ORDER_LITTLE)
|
||||
configs = [_make_lvgl_config(["disp_a", "disp_b"])]
|
||||
final_validation(configs)
|
||||
assert configs[0][CONF_BYTE_ORDER] == BYTE_ORDER_LITTLE
|
||||
|
||||
def test_inconsistent_displays_raises(self) -> None:
|
||||
"""Displays with different byte_order should raise an error."""
|
||||
add_metadata(ID("disp_a"), 320, 240, byte_order=BYTE_ORDER_BIG)
|
||||
add_metadata(ID("disp_b"), 128, 64, byte_order=BYTE_ORDER_LITTLE)
|
||||
configs = [_make_lvgl_config(["disp_a", "disp_b"])]
|
||||
with pytest.raises(Invalid, match="same byte_order"):
|
||||
final_validation(configs)
|
||||
|
||||
|
||||
class TestHasWriterCheck:
|
||||
"""Test that LVGL rejects displays with has_writer set."""
|
||||
|
||||
def test_display_with_writer_raises(self) -> None:
|
||||
"""Display with lambda/pages/auto_clear should be rejected."""
|
||||
add_metadata(ID("my_disp"), 320, 240, has_writer=True)
|
||||
configs = [_make_lvgl_config(["my_disp"])]
|
||||
with pytest.raises(Invalid, match="not compatible with LVGL"):
|
||||
final_validation(configs)
|
||||
|
||||
def test_display_without_writer_passes(self) -> None:
|
||||
"""Display without writer should pass."""
|
||||
add_metadata(ID("my_disp"), 320, 240, has_writer=False)
|
||||
configs = [_make_lvgl_config(["my_disp"])]
|
||||
final_validation(configs)
|
||||
|
||||
|
||||
class TestRotationCheck:
|
||||
"""Test that LVGL rejects displays with non-zero rotation."""
|
||||
|
||||
def test_display_with_rotation_raises(self) -> None:
|
||||
"""Display with rotation should be rejected."""
|
||||
add_metadata(ID("my_disp"), 320, 240, rotation=90)
|
||||
configs = [_make_lvgl_config(["my_disp"])]
|
||||
with pytest.raises(Invalid, match="rotation.*not compatible with LVGL"):
|
||||
final_validation(configs)
|
||||
|
||||
def test_display_without_rotation_passes(self) -> None:
|
||||
"""Display with rotation=0 should pass."""
|
||||
add_metadata(ID("my_disp"), 320, 240, rotation=0)
|
||||
configs = [_make_lvgl_config(["my_disp"])]
|
||||
final_validation(configs)
|
||||
|
||||
|
||||
class TestDrawRoundingMerge:
|
||||
"""Test that display draw_rounding is merged into LVGL config."""
|
||||
|
||||
def test_display_draw_rounding_overrides_lower(self) -> None:
|
||||
"""Display draw_rounding higher than LVGL default should win."""
|
||||
add_metadata(ID("my_disp"), 320, 240, draw_rounding=8)
|
||||
configs = [_make_lvgl_config(["my_disp"])]
|
||||
final_validation(configs)
|
||||
assert configs[0]["draw_rounding"] == 8
|
||||
|
||||
def test_display_draw_rounding_does_not_lower(self) -> None:
|
||||
"""Display draw_rounding lower than LVGL config should not reduce it."""
|
||||
add_metadata(ID("my_disp"), 320, 240, draw_rounding=1)
|
||||
configs = [_make_lvgl_config(["my_disp"])]
|
||||
configs[0]["draw_rounding"] = 4
|
||||
final_validation(configs)
|
||||
assert configs[0]["draw_rounding"] == 4
|
||||
|
||||
def test_zero_draw_rounding_no_change(self) -> None:
|
||||
"""Display with draw_rounding=0 should not affect LVGL config."""
|
||||
add_metadata(ID("my_disp"), 320, 240, draw_rounding=0)
|
||||
configs = [_make_lvgl_config(["my_disp"])]
|
||||
final_validation(configs)
|
||||
assert configs[0]["draw_rounding"] == 2
|
||||
@@ -3,22 +3,15 @@
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from esphome.components.display import (
|
||||
DisplayMetaData,
|
||||
get_all_display_metadata,
|
||||
get_display_metadata,
|
||||
)
|
||||
from esphome.components.const import BYTE_ORDER_BIG
|
||||
from esphome.components.display import get_all_display_metadata, get_display_metadata
|
||||
from esphome.components.esp32 import (
|
||||
KEY_BOARD,
|
||||
KEY_VARIANT,
|
||||
VARIANT_ESP32,
|
||||
VARIANT_ESP32S3,
|
||||
)
|
||||
from esphome.components.mipi_spi.display import (
|
||||
CONFIG_SCHEMA,
|
||||
FINAL_VALIDATE_SCHEMA,
|
||||
get_instance,
|
||||
)
|
||||
from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA
|
||||
from esphome.const import PlatformFramework
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
@@ -38,38 +31,32 @@ def test_metadata_native_quad_default_test_card(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3},
|
||||
)
|
||||
config = validated_config({"model": "JC3636W518"})
|
||||
get_instance(config)
|
||||
meta = get_display_metadata(str(config["id"]))
|
||||
config = CONFIG_SCHEMA({"model": "JC3636W518", "id": "jc3232w518"})
|
||||
meta = get_display_metadata(config["id"])
|
||||
assert meta is not None
|
||||
assert meta.width == 360
|
||||
assert meta.height == 360
|
||||
# final validation auto-enables show_test_card when no drawing methods are configured
|
||||
assert meta.has_writer is True
|
||||
assert meta.has_hardware_rotation is True
|
||||
assert meta.byte_order == BYTE_ORDER_BIG
|
||||
|
||||
|
||||
def test_metadata_single_mode_with_dc_pin(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""A single-mode display with no explicit drawing gets a test card from final validation."""
|
||||
"""A single-mode display with no explicit drawing gets metadata from schema validation."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
config = validated_config(
|
||||
{
|
||||
"model": "ST7735",
|
||||
"dc_pin": 18,
|
||||
}
|
||||
config = CONFIG_SCHEMA(
|
||||
{"model": "ST7735", "dc_pin": 18, "id": "single_mode_with_dc_pin"}
|
||||
)
|
||||
get_instance(config)
|
||||
meta = get_display_metadata(str(config["id"]))
|
||||
meta = get_display_metadata(config["id"])
|
||||
assert meta is not None
|
||||
assert meta.width == 128
|
||||
assert meta.height == 160
|
||||
assert meta.has_writer is True
|
||||
assert meta.has_hardware_rotation is True
|
||||
assert meta.byte_order == BYTE_ORDER_BIG
|
||||
|
||||
|
||||
def test_metadata_custom_dimensions(
|
||||
@@ -80,47 +67,22 @@ def test_metadata_custom_dimensions(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
config = validated_config(
|
||||
config = CONFIG_SCHEMA(
|
||||
{
|
||||
"model": "custom",
|
||||
"dc_pin": 18,
|
||||
"dimensions": {"width": 480, "height": 320},
|
||||
"init_sequence": [[0xA0, 0x01]],
|
||||
"id": "custom_dimensions",
|
||||
}
|
||||
)
|
||||
get_instance(config)
|
||||
meta = get_display_metadata(str(config["id"]))
|
||||
meta = get_display_metadata(config["id"])
|
||||
assert meta is not None
|
||||
assert meta.width == 480
|
||||
assert meta.height == 320
|
||||
# final validation auto-enables show_test_card
|
||||
assert meta.has_writer is True
|
||||
assert meta.has_hardware_rotation is True
|
||||
|
||||
|
||||
def test_metadata_with_test_card_has_writer(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""When show_test_card is enabled, has_writer should be True."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
config = validated_config(
|
||||
{
|
||||
"model": "custom",
|
||||
"dc_pin": 18,
|
||||
"dimensions": {"width": 240, "height": 240},
|
||||
"init_sequence": [[0xA0, 0x01]],
|
||||
"show_test_card": True,
|
||||
}
|
||||
)
|
||||
get_instance(config)
|
||||
meta = get_display_metadata(str(config["id"]))
|
||||
assert meta is not None
|
||||
assert meta.has_writer is True
|
||||
|
||||
|
||||
def test_metadata_no_swap_xy_not_full_hardware_rotation(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
@@ -130,9 +92,8 @@ def test_metadata_no_swap_xy_not_full_hardware_rotation(
|
||||
platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3},
|
||||
)
|
||||
# JC3248W535 has swap_xy=cv.UNDEFINED -> transforms={mirror_x, mirror_y} only
|
||||
config = validated_config({"model": "JC3248W535"})
|
||||
get_instance(config)
|
||||
meta = get_display_metadata(str(config["id"]))
|
||||
config = CONFIG_SCHEMA({"model": "JC3248W535", "id": "jc3248w535"})
|
||||
meta = get_display_metadata(config["id"])
|
||||
assert meta is not None
|
||||
assert meta.has_hardware_rotation is False
|
||||
|
||||
@@ -145,7 +106,7 @@ def test_metadata_multiple_displays_independent(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
config_a = validated_config(
|
||||
CONFIG_SCHEMA(
|
||||
{
|
||||
"id": "disp_a",
|
||||
"model": "custom",
|
||||
@@ -154,7 +115,7 @@ def test_metadata_multiple_displays_independent(
|
||||
"init_sequence": [[0xA0, 0x01]],
|
||||
}
|
||||
)
|
||||
config_b = validated_config(
|
||||
CONFIG_SCHEMA(
|
||||
{
|
||||
"id": "disp_b",
|
||||
"model": "custom",
|
||||
@@ -163,13 +124,16 @@ def test_metadata_multiple_displays_independent(
|
||||
"init_sequence": [[0xA0, 0x01]],
|
||||
}
|
||||
)
|
||||
get_instance(config_a)
|
||||
get_instance(config_b)
|
||||
|
||||
all_meta = get_all_display_metadata()
|
||||
# final validation auto-enables show_test_card for both
|
||||
assert all_meta["disp_a"] == DisplayMetaData(320, 240, True, True)
|
||||
assert all_meta["disp_b"] == DisplayMetaData(128, 64, True, True)
|
||||
assert all_meta["disp_a"].width == 320
|
||||
assert all_meta["disp_a"].height == 240
|
||||
assert all_meta["disp_a"].has_hardware_rotation is True
|
||||
assert all_meta["disp_a"].byte_order == BYTE_ORDER_BIG
|
||||
assert all_meta["disp_b"].width == 128
|
||||
assert all_meta["disp_b"].height == 64
|
||||
assert all_meta["disp_b"].has_hardware_rotation is True
|
||||
assert all_meta["disp_b"].byte_order == BYTE_ORDER_BIG
|
||||
|
||||
|
||||
def test_metadata_via_code_generation_native(
|
||||
@@ -179,12 +143,13 @@ def test_metadata_via_code_generation_native(
|
||||
"""Full code generation for native.yaml should produce correct metadata."""
|
||||
generate_main(component_fixture_path("native.yaml"))
|
||||
all_meta = get_all_display_metadata()
|
||||
# native.yaml: model JC3636W518 -> 360x360, no writer, full hardware rotation
|
||||
# native.yaml: model JC3636W518 -> 360x360, full hardware rotation
|
||||
assert len(all_meta) == 1
|
||||
meta = next(iter(all_meta.values()))
|
||||
assert meta == DisplayMetaData(
|
||||
width=360, height=360, has_writer=True, has_hardware_rotation=True
|
||||
)
|
||||
assert meta.width == 360
|
||||
assert meta.height == 360
|
||||
assert meta.has_hardware_rotation is True
|
||||
assert meta.byte_order == BYTE_ORDER_BIG
|
||||
|
||||
|
||||
def test_metadata_via_code_generation_lvgl(
|
||||
@@ -194,9 +159,10 @@ def test_metadata_via_code_generation_lvgl(
|
||||
"""Full code generation for lvgl.yaml should produce correct metadata."""
|
||||
generate_main(component_fixture_path("lvgl.yaml"))
|
||||
all_meta = get_all_display_metadata()
|
||||
# lvgl.yaml: model ST7735 -> 128x160, no writer (lvgl draws directly), full hw rotation
|
||||
# lvgl.yaml: model ST7735 -> 128x160, full hw rotation
|
||||
assert len(all_meta) == 1
|
||||
meta = next(iter(all_meta.values()))
|
||||
assert meta == DisplayMetaData(
|
||||
width=128, height=160, has_writer=False, has_hardware_rotation=True
|
||||
)
|
||||
assert meta.width == 128
|
||||
assert meta.height == 160
|
||||
assert meta.has_hardware_rotation is True
|
||||
assert meta.byte_order == BYTE_ORDER_BIG
|
||||
|
||||
Reference in New Issue
Block a user