diff --git a/.agents/skills/implement-widget-tests/SKILL.md b/.agents/skills/implement-widget-tests/SKILL.md index 024537c..54f17a8 100644 --- a/.agents/skills/implement-widget-tests/SKILL.md +++ b/.agents/skills/implement-widget-tests/SKILL.md @@ -354,9 +354,9 @@ m = _compute_metrics(56) # row_h = widget h / number of rows # "none" -> (0, 0) # Content width (cw): # cw = w - x_off - right_inset -# _card_insets(m, card_style, grayscale_levels) returns +# _card_insets(m, card_style, display_levels) returns # (x_off, r_inset, bar_width). Derive lpad/rpad from the result: -# x_off, r_inset, _ = _card_insets(m, card_style, grayscale_levels) +# x_off, r_inset, _ = _card_insets(m, card_style, display_levels) # lpad = m.padding if x_off == 0 else 0 # rpad = m.padding if r_inset == 0 else 0 # Icon circle left arc lands at x_off + lpad, NOT x_off + m.padding. @@ -379,9 +379,9 @@ m = _compute_metrics(56) # row_h = widget h / number of rows bounding box of non-white pixels. 4. **2-level display tests**: When the widget uses gray elements - (dividers, bars, left_bar), test the `grayscale_levels=2` path + (dividers, bars, left_bar), test the `display_levels=2` path that widens them. Pass it via config: - `config = {**self._CONFIG, "grayscale_levels": 2}`. + `config = {**self._CONFIG, "display_levels": 2}`. 5. **Test each `card_style` variant**: For card-style widgets, test all three styles (`"border"`, `"left_bar"`, `"none"`) in separate @@ -395,7 +395,7 @@ m = _compute_metrics(56) # row_h = widget h / number of rows ```python icon_stroke_w = ( - m.border * 3 if grayscale_levels <= 2 else m.border + m.border * 3 if display_levels <= 2 else m.border ) ring_y1 = icon_cy - icon_r + icon_stroke_w // 2 + 3 ring_y2 = icon_cy - m.icon_inner // 2 - 1 diff --git a/.agents/skills/implement-widget/SKILL.md b/.agents/skills/implement-widget/SKILL.md index 61bc4fc..e260ab6 100644 --- a/.agents/skills/implement-widget/SKILL.md +++ b/.agents/skills/implement-widget/SKILL.md @@ -114,7 +114,7 @@ the context dict with `**_color_context()` so templates can use `"left_bar"` → `(bar_w + m.padding, 0)`; `"none"` → `(0, 0)`. Content starts at `x_off`; content width = `w - x_off - right_inset`. - Always pass `grayscale_levels` from config. + Always pass `display_levels` from config. - **`card_row`** expects all sizes from `WidgetMetrics` fields plus `icon_svg` (pre-built SVG string, empty string for letter fallback) and `letter` (single uppercase char, empty string when icon_svg is @@ -179,7 +179,7 @@ white). card_style=card_style, radius=m.radius, border=m.border, padding=m.padding, left_bar=m.left_bar, - grayscale_levels=grayscale_levels) -%} + display_levels=display_levels) -%} {%- for row in rows -%} {{ card_row( x=x_off, y=row.y, @@ -266,7 +266,7 @@ def _build_{widget_type}_context( Args: widget: Widget config dict with x, w, h, entities, card_style, title. - config: DisplayConfig with states and grayscale_levels. + config: DisplayConfig with states and display_levels. Returns: Template context dict. @@ -279,7 +279,7 @@ def _build_{widget_type}_context( ) entities = widget.get("entities", []) states = config.get("states", {}) - grayscale_levels = config.get("grayscale_levels", 16) + display_levels = config.get("display_levels", 16) n = len(entities) if n == 0: @@ -294,7 +294,7 @@ def _build_{widget_type}_context( row_h = h // n m = _compute_metrics(row_h) x_off, r_inset, bar_width = _card_insets( - m, card_style, grayscale_levels + m, card_style, display_levels ) lpad = m.padding if x_off == 0 else 0 rpad = m.padding if r_inset == 0 else 0 @@ -341,7 +341,7 @@ def _build_{widget_type}_context( "w": w, "h": h, "card_style": card_style, - "grayscale_levels": grayscale_levels, + "display_levels": display_levels, "rows": rows, "row_h": row_h, "lpad": lpad, @@ -535,7 +535,7 @@ behavior. ```python # Widen the outline stroke on 2-level displays. icon_stroke_w = ( - m.border * 3 if grayscale_levels <= 2 else m.border + m.border * 3 if display_levels <= 2 else m.border ) ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index e025781..6fa94a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 conditions are met the tile renders inverted (solid black card, white text/icon) as an e-ink "needs attention" signal. +### Fixed + +- **Seeed reTerminal E1003** device preset now declares its correct + native landscape orientation, fixing a spurious 90° rotation that + produced a portrait PNG for the default landscape setup. Existing + dashboards are fixed automatically on upgrade via a config entry + migration; no manual reconfiguration is needed. + ## [0.6.0] - 2026-07-07 ### Added diff --git a/README.md b/README.md index bcd805f..1602387 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ Supported device presets: | TRMNL X | 1872 × 1404 | 16 | | TRMNL RGB | 2560 × 1440 | 2 (black & white) | | Seeed reTerminal E1001 | 800 × 480 | 4 | -| Seeed reTerminal E1003 | 1404 × 1872 | 16 | +| Seeed reTerminal E1003 | 1872 × 1404 | 16 | | Custom | user-defined | 16 | ### Step 2 -- Image delivery diff --git a/custom_components/eink_dashboard/__init__.py b/custom_components/eink_dashboard/__init__.py index 76d70be..e133d74 100644 --- a/custom_components/eink_dashboard/__init__.py +++ b/custom_components/eink_dashboard/__init__.py @@ -40,8 +40,8 @@ from .battery import resolve_battery_level from .const import ( + DEFAULT_DISPLAY_LEVELS, DEFAULT_EXPOSURE, - DEFAULT_GRAYSCALE_LEVELS, DEFAULT_HEIGHT, DEFAULT_SATURATION, DEFAULT_WIDTH, @@ -51,6 +51,7 @@ NumberFormat, TimeFormat, WidgetType, + resolve_display, ) from .http import EinkLayoutView, EinkPublicImageView from .store import EinkDashboardStore @@ -238,7 +239,7 @@ async def _build_display_config( entry_id: Config entry ID present in ``hass.data[DOMAIN]``. Returns: - Dict with ``width``, ``height``, ``grayscale_levels``, + Dict with ``width``, ``height``, ``display_levels``, ``number_format``, ``language``, ``first_weekday``, ``date_format``, ``time_format``, ``states``, and (when battery data is available) ``device_battery_level`` and @@ -267,8 +268,8 @@ async def _build_display_config( config: dict[str, Any] = { "width": entry.options.get("width", DEFAULT_WIDTH), "height": entry.options.get("height", DEFAULT_HEIGHT), - "grayscale_levels": entry.options.get( - "grayscale_levels", DEFAULT_GRAYSCALE_LEVELS + "display_levels": entry.options.get( + "display_levels", DEFAULT_DISPLAY_LEVELS ), "color_scheme": entry.options.get("color_scheme"), "number_format": number_format, @@ -731,6 +732,16 @@ async def async_migrate_entry( in epaper-dithering; ``contrast`` is superseded by ``exposure``. Both new keys default to 1.0 (no change). + Version 1.2 → 1.3: rename ``grayscale_levels`` to + ``display_levels`` so the option name also fits future color + e-ink displays. + + Version 1.3 → 1.4: recompute ``width``, ``height``, and + ``rotation`` for ``reterminal_e1003`` entries. The device's + native orientation preset was previously wrong, so entries + created before the fix have a stale ``rotation`` baked in that + produces a 90°-rotated image. + Args: hass: Home Assistant instance. config_entry: The config entry to migrate. @@ -755,6 +766,42 @@ async def async_migrate_entry( minor_version=2, ) + if config_entry.minor_version == 2: + _LOGGER.debug( + "Migrating %s from minor version %d to 3", + config_entry.entry_id, + config_entry.minor_version, + ) + new_options = dict(config_entry.options) + if "grayscale_levels" in new_options: + new_options["display_levels"] = new_options.pop("grayscale_levels") + hass.config_entries.async_update_entry( + config_entry, + options=new_options, + minor_version=3, + ) + + if config_entry.minor_version == 3: + _LOGGER.debug( + "Migrating %s from minor version %d to 4", + config_entry.entry_id, + config_entry.minor_version, + ) + new_options = dict(config_entry.options) + if new_options.get("device_model") == "reterminal_e1003": + orientation = new_options.get("orientation", "landscape") + width, height, rotation, _preset = resolve_display( + "reterminal_e1003", orientation + ) + new_options["width"] = width + new_options["height"] = height + new_options["rotation"] = rotation + hass.config_entries.async_update_entry( + config_entry, + options=new_options, + minor_version=4, + ) + return True diff --git a/custom_components/eink_dashboard/config_flow.py b/custom_components/eink_dashboard/config_flow.py index 979fa48..afea3c7 100644 --- a/custom_components/eink_dashboard/config_flow.py +++ b/custom_components/eink_dashboard/config_flow.py @@ -17,7 +17,7 @@ from __future__ import annotations from copy import deepcopy -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.parse import urlparse import voluptuous as vol @@ -47,10 +47,13 @@ TextSelectorType, ) +if TYPE_CHECKING: + from collections.abc import Mapping + from .const import ( + DEFAULT_DISPLAY_LEVELS, DEFAULT_DITHER_ALGORITHM, DEFAULT_EXPOSURE, - DEFAULT_GRAYSCALE_LEVELS, DEFAULT_HEIGHT, DEFAULT_MEASURED_PALETTE, DEFAULT_OPTIMIZE, @@ -179,6 +182,76 @@ def _build_user_schema( ) +def _build_advanced_section( + opts: Mapping[str, Any], display_levels: int +) -> Any: + """Build the collapsed Advanced section of the display settings form. + + Args: + opts: Currently stored config entry options, used as field + defaults. + display_levels: Currently stored display_levels value. When it + equals 256, exposure/saturation are omitted since they are + only forwarded to dither_image(), which is never called on + the 256-level passthrough path. + + Returns: + A voluptuous section wrapping dither_algorithm, measured_palette, + and (unless display_levels == 256) exposure and saturation. + """ + advanced_fields: dict = { + vol.Optional( + "dither_algorithm", + default=opts.get( + "dither_algorithm", + DEFAULT_DITHER_ALGORITHM, + ), + ): SelectSelector( + SelectSelectorConfig( + options=_DITHER_ALGO_OPTIONS, + translation_key="dither_algorithm", + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional( + "measured_palette", + default=opts.get( + "measured_palette", + DEFAULT_MEASURED_PALETTE, + ), + ): SelectSelector( + SelectSelectorConfig( + options=_MEASURED_PALETTE_OPTIONS, + translation_key="measured_palette", + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + if display_levels != 256: + advanced_fields[ + vol.Optional( + "exposure", + default=opts.get("exposure", DEFAULT_EXPOSURE), + ) + ] = vol.All( + vol.Coerce(float), + vol.Range(min=0.0, max=10.0), + ) + advanced_fields[ + vol.Optional( + "saturation", + default=opts.get("saturation", DEFAULT_SATURATION), + ) + ] = vol.All( + vol.Coerce(float), + vol.Range(min=0.0, max=10.0), + ) + return flow_section( + vol.Schema(advanced_fields), + {"collapsed": True}, + ) + + _STEP_CUSTOM_RESOLUTION_SCHEMA = vol.Schema( { vol.Required("width", default=DEFAULT_WIDTH): _POSITIVE_INT, @@ -230,7 +303,7 @@ class EinkDashboardConfigFlow(ConfigFlow, domain=DOMAIN): """Multi-step config flow for creating a new dashboard entry.""" VERSION = 1 - MINOR_VERSION = 2 + MINOR_VERSION = 4 def __init__(self) -> None: """Initialise flow state.""" @@ -293,7 +366,7 @@ async def async_step_user( "height": height, "rotation": rotation, "optimize": preset.optimize, - "grayscale_levels": preset.grayscale_levels, + "display_levels": preset.display_levels, "dither_algorithm": preset.dither_algorithm, "color_scheme": preset.color_scheme, "measured_palette": preset.measured_palette, @@ -337,7 +410,7 @@ async def async_step_screen_portion( { "rotation": rotation, "optimize": preset.optimize, - "grayscale_levels": preset.grayscale_levels, + "display_levels": preset.display_levels, "dither_algorithm": preset.dither_algorithm, "color_scheme": preset.color_scheme, "measured_palette": preset.measured_palette, @@ -354,7 +427,7 @@ async def async_step_screen_portion( "height": final_height, "rotation": rotation, "optimize": preset.optimize, - "grayscale_levels": preset.grayscale_levels, + "display_levels": preset.display_levels, "dither_algorithm": preset.dither_algorithm, "color_scheme": preset.color_scheme, "measured_palette": preset.measured_palette, @@ -390,7 +463,7 @@ async def async_step_custom_resolution( { "rotation": 0, "optimize": DEFAULT_OPTIMIZE, - "grayscale_levels": DEFAULT_GRAYSCALE_LEVELS, + "display_levels": DEFAULT_DISPLAY_LEVELS, "dither_algorithm": DEFAULT_DITHER_ALGORITHM, "color_scheme": None, "measured_palette": DEFAULT_MEASURED_PALETTE, @@ -513,7 +586,7 @@ def _save_display_entry( Args: extra: Display-specific keys (width, height, - rotation, optimize, grayscale_levels, and + rotation, optimize, display_levels, and optionally screen_portion) merged after ``self._data``. @@ -774,7 +847,7 @@ async def async_step_device_settings( "height": fh, "rotation": rot, "optimize": preset.optimize, - "grayscale_levels": preset.grayscale_levels, + "display_levels": preset.display_levels, "dither_algorithm": preset.dither_algorithm, "color_scheme": preset.color_scheme, "measured_palette": preset.measured_palette, @@ -792,7 +865,7 @@ async def async_step_device_settings( "height": height, "rotation": rotation, "optimize": preset.optimize, - "grayscale_levels": preset.grayscale_levels, + "display_levels": preset.display_levels, "dither_algorithm": preset.dither_algorithm, "color_scheme": preset.color_scheme, "measured_palette": preset.measured_palette, @@ -855,7 +928,7 @@ async def async_step_screen_portion_options( "height": final_height, "rotation": rotation, "optimize": preset.optimize, - "grayscale_levels": preset.grayscale_levels, + "display_levels": preset.display_levels, "dither_algorithm": preset.dither_algorithm, "color_scheme": preset.color_scheme, "measured_palette": preset.measured_palette, @@ -885,7 +958,7 @@ async def async_step_custom_resolution( "height": validated["height"], "rotation": 0, "optimize": DEFAULT_OPTIMIZE, - "grayscale_levels": DEFAULT_GRAYSCALE_LEVELS, + "display_levels": DEFAULT_DISPLAY_LEVELS, "dither_algorithm": DEFAULT_DITHER_ALGORITHM, "color_scheme": None, "measured_palette": DEFAULT_MEASURED_PALETTE, @@ -984,92 +1057,41 @@ async def async_step_display_settings( ) -> ConfigFlowResult: """Update refresh interval, optimize, and image quality settings.""" opts = self.config_entry.options - grayscale_levels = opts.get( - "grayscale_levels", DEFAULT_GRAYSCALE_LEVELS + optimize = opts.get("optimize", DEFAULT_OPTIMIZE) + device_model = opts.get("device_model", "") + preset = DEVICE_PRESETS.get(device_model) + default_display_levels = ( + preset.display_levels if preset else DEFAULT_DISPLAY_LEVELS ) - # exposure/saturation are only forwarded to dither_image(), which - # is never called on the 256-level passthrough path, so those - # controls serve no purpose there. - advanced_fields: dict = { - vol.Optional( - "dither_algorithm", - default=opts.get( - "dither_algorithm", - DEFAULT_DITHER_ALGORITHM, - ), - ): SelectSelector( - SelectSelectorConfig( - options=_DITHER_ALGO_OPTIONS, - translation_key="dither_algorithm", - mode=SelectSelectorMode.DROPDOWN, - ) - ), - vol.Optional( - "measured_palette", - default=opts.get( - "measured_palette", - DEFAULT_MEASURED_PALETTE, - ), - ): SelectSelector( - SelectSelectorConfig( - options=_MEASURED_PALETTE_OPTIONS, - translation_key="measured_palette", - mode=SelectSelectorMode.DROPDOWN, - ) - ), + display_levels = opts.get("display_levels", default_display_levels) + schema_fields: dict = { + vol.Required( + "update_interval", + default=opts.get("update_interval", DEFAULT_UPDATE_INTERVAL), + ): _POSITIVE_INT, + vol.Optional("optimize", default=optimize): bool, vol.Optional( - "grayscale_levels", - default=grayscale_levels, + "display_levels", + default=display_levels, ): vol.All( vol.Coerce(int), vol.In([2, 4, 16, 256]), ), } - if grayscale_levels != 256: - advanced_fields[ - vol.Optional( - "exposure", - default=opts.get("exposure", DEFAULT_EXPOSURE), - ) - ] = vol.All( - vol.Coerce(float), - vol.Range(min=0.0, max=10.0), + # The Advanced section only affects optimize_for_eink(), which + # early-returns when optimize is off, so it is only shown once + # optimize is enabled. + if optimize: + schema_fields[vol.Required("advanced_section")] = ( + _build_advanced_section(opts, display_levels) ) - advanced_fields[ - vol.Optional( - "saturation", - default=opts.get("saturation", DEFAULT_SATURATION), - ) - ] = vol.All( - vol.Coerce(float), - vol.Range(min=0.0, max=10.0), - ) - schema = vol.Schema( - { - vol.Required( - "update_interval", - default=opts.get( - "update_interval", DEFAULT_UPDATE_INTERVAL - ), - ): _POSITIVE_INT, - vol.Optional( - "optimize", - default=opts.get("optimize", DEFAULT_OPTIMIZE), - ): bool, - vol.Required("advanced_section"): flow_section( - vol.Schema(advanced_fields), - {"collapsed": True}, - ), - } - ) + schema = vol.Schema(schema_fields) if user_input is not None: validated = schema(user_input) section = validated.pop("advanced_section", {}) return self.async_create_entry( data={**opts, **validated, **section}, ) - device_model = opts.get("device_model", "") - preset = DEVICE_PRESETS.get(device_model) if preset and preset.integration_dithers: optimize_note = ( "This device's Home Assistant integration handles image" diff --git a/custom_components/eink_dashboard/const.py b/custom_components/eink_dashboard/const.py index 71e0e57..b3e970d 100644 --- a/custom_components/eink_dashboard/const.py +++ b/custom_components/eink_dashboard/const.py @@ -31,7 +31,7 @@ DEFAULT_UPDATE_INTERVAL = 60 DEFAULT_GRAYSCALE_DEPTH = 8 DEFAULT_OPTIMIZE = False -DEFAULT_GRAYSCALE_LEVELS = 16 +DEFAULT_DISPLAY_LEVELS = 16 DEFAULT_DITHER_ALGORITHM = "floyd_steinberg" DEFAULT_MEASURED_PALETTE = "auto" DEFAULT_EXPOSURE = 1.0 @@ -83,7 +83,7 @@ class DevicePreset: label: str width: int height: int - grayscale_levels: int + display_levels: int optimize: bool manufacturer: str native_landscape: bool = False @@ -96,7 +96,7 @@ class DevicePreset: measured_palette: str = DEFAULT_MEASURED_PALETTE """Measured palette key for photographically calibrated dithering. ``"auto"`` means use the idealized ``ColorScheme`` derived from - ``color_scheme`` or ``grayscale_levels``. Non-auto values match + ``color_scheme`` or ``display_levels``. Non-auto values match keys in ``optimize._MEASURED_PALETTES``.""" @@ -183,11 +183,12 @@ class DevicePreset: ), "reterminal_e1003": DevicePreset( "reTerminal E1003", - 1404, 1872, + 1404, 16, False, "Seeed", + native_landscape=True, integration_dithers=True, ), "custom": DevicePreset( diff --git a/custom_components/eink_dashboard/frontend/src/types/ha.d.ts b/custom_components/eink_dashboard/frontend/src/types/ha.d.ts index 834df03..3566885 100644 --- a/custom_components/eink_dashboard/frontend/src/types/ha.d.ts +++ b/custom_components/eink_dashboard/frontend/src/types/ha.d.ts @@ -108,11 +108,11 @@ export interface DisplayConfig { /** Canvas height in pixels. */ height: number; /** - * Number of discrete gray levels the display supports. + * Number of discrete levels the display supports. * 2 = black-and-white only; 16 = full grayscale. * Used to widen dividers and borders for low-depth displays. */ - grayscale_levels?: number; + display_levels?: number; } /** Static device metadata returned by the `eink_dashboard/layout` WebSocket command. */ diff --git a/custom_components/eink_dashboard/http.py b/custom_components/eink_dashboard/http.py index 7035cb7..c1ac7d5 100644 --- a/custom_components/eink_dashboard/http.py +++ b/custom_components/eink_dashboard/http.py @@ -74,7 +74,7 @@ async def get(self, request: web.Request, entry_id: str) -> web.Response: "display": { "width": width, "height": height, - "grayscale_levels": preset.grayscale_levels, + "display_levels": preset.display_levels, }, "device": { "name": entry.title, diff --git a/custom_components/eink_dashboard/image.py b/custom_components/eink_dashboard/image.py index 2d59e85..0813cb0 100644 --- a/custom_components/eink_dashboard/image.py +++ b/custom_components/eink_dashboard/image.py @@ -50,9 +50,9 @@ ) from .battery import resolve_battery_level from .const import ( + DEFAULT_DISPLAY_LEVELS, DEFAULT_DITHER_ALGORITHM, DEFAULT_EXPOSURE, - DEFAULT_GRAYSCALE_LEVELS, DEFAULT_HEIGHT, DEFAULT_MEASURED_PALETTE, DEFAULT_OPTIMIZE, @@ -246,8 +246,8 @@ async def _async_refresh(self, _now: Any) -> None: "optimize": self._entry.options.get( "optimize", DEFAULT_OPTIMIZE ), - "grayscale_levels": self._entry.options.get( - "grayscale_levels", DEFAULT_GRAYSCALE_LEVELS + "display_levels": self._entry.options.get( + "display_levels", DEFAULT_DISPLAY_LEVELS ), "exposure": self._entry.options.get( "exposure", DEFAULT_EXPOSURE diff --git a/custom_components/eink_dashboard/optimize.py b/custom_components/eink_dashboard/optimize.py index 3f4cac6..19658b3 100644 --- a/custom_components/eink_dashboard/optimize.py +++ b/custom_components/eink_dashboard/optimize.py @@ -35,16 +35,16 @@ from PIL import Image, ImageOps from .const import ( + DEFAULT_DISPLAY_LEVELS, DEFAULT_DITHER_ALGORITHM, DEFAULT_EXPOSURE, - DEFAULT_GRAYSCALE_LEVELS, DEFAULT_MEASURED_PALETTE, DEFAULT_SATURATION, ) _LOGGER = logging.getLogger(__name__) -# Maps grayscale_levels config values to epaper-dithering color schemes. +# Maps display_levels config values to epaper-dithering color schemes. _GRAYSCALE_SCHEMES: dict[int, ColorScheme] = { 2: ColorScheme.MONO, 4: ColorScheme.GRAYSCALE_4, @@ -115,12 +115,12 @@ def optimize_for_eink( (bool, required to enable the pipeline), ``exposure`` (float, default 1.0; linear-RGB brightness multiplier applied inside ``dither_image()``; no effect when - ``grayscale_levels`` is 256), ``saturation`` + ``display_levels`` is 256), ``saturation`` (float, default 1.0; OKLab chroma multiplier applied inside ``dither_image()``; no effect when - ``grayscale_levels`` is 256), ``color_scheme`` (str, one + ``display_levels`` is 256), ``color_scheme`` (str, one of ``"bwr"``, ``"bwy"``, ``"bwry"``, ``"bwgbry"``; ``None`` - or absent means grayscale), ``grayscale_levels`` (int, + or absent means grayscale), ``display_levels`` (int, one of 2/4/16/256, default 16; ignored when ``color_scheme`` is set; 256 means passthrough (only autocontrast is applied); 8 is reserved for future @@ -133,18 +133,18 @@ def optimize_for_eink( its calibrated ``ColorPalette`` is passed to ``dither_image()`` instead of the idealized ``ColorScheme``. The output mode (``"1"`` / ``"L"`` / - ``"RGB"``) is derived from ``grayscale_levels`` / + ``"RGB"``) is derived from ``display_levels`` / ``color_scheme`` regardless of palette type. Returns: Processed PIL image. Mode is ``"RGB"`` for color schemes, - ``"1"`` when ``grayscale_levels`` maps to + ``"1"`` when ``display_levels`` maps to ``ColorScheme.MONO``, ``"L"`` for all other grayscale schemes. Raises: ValueError: If ``color_scheme`` is set but not in - ``_COLOR_SCHEMES``, or if ``grayscale_levels`` is less + ``_COLOR_SCHEMES``, or if ``display_levels`` is less than 256 but not in ``_GRAYSCALE_SCHEMES``. """ if not config.get("optimize", False): @@ -208,12 +208,12 @@ def optimize_for_eink( return img.convert("RGB") # Grayscale path: quantise to the configured number of levels. - colors = config.get("grayscale_levels", DEFAULT_GRAYSCALE_LEVELS) + colors = config.get("display_levels", DEFAULT_DISPLAY_LEVELS) if colors < 256: scheme = _GRAYSCALE_SCHEMES.get(colors) if scheme is None: raise ValueError( - f"Unsupported grayscale_levels value {colors!r}; " + f"Unsupported display_levels value {colors!r}; " f"expected one of {sorted(_GRAYSCALE_SCHEMES)}." ) if measured_palette is not None and len(measured_palette.colors) > 2: @@ -236,9 +236,9 @@ def optimize_for_eink( exposure=exposure, saturation=saturation, ) - # Output mode is derived from grayscale_levels, not the palette + # Output mode is derived from display_levels, not the palette # type, so MONO_4_26 (a measured palette for a 2-color display) - # still produces mode "1" when grayscale_levels == 2. + # still produces mode "1" when display_levels == 2. img = ( img.convert("1") if scheme is ColorScheme.MONO diff --git a/custom_components/eink_dashboard/render.py b/custom_components/eink_dashboard/render.py index 1fe3d69..919e715 100644 --- a/custom_components/eink_dashboard/render.py +++ b/custom_components/eink_dashboard/render.py @@ -468,7 +468,7 @@ def _compute_metrics(row_h: int) -> WidgetMetrics: DEFAULT_METRICS: WidgetMetrics = _compute_metrics(DEFAULT_ROW_H) -def _left_bar_width(m: WidgetMetrics, grayscale_levels: int) -> int: +def _left_bar_width(m: WidgetMetrics, display_levels: int) -> int: """Return the rendered width of a left_bar card decoration. On 2-level displays (TRMNL) the bar is tripled so the @@ -476,13 +476,13 @@ def _left_bar_width(m: WidgetMetrics, grayscale_levels: int) -> int: Args: m: Proportional metrics from ``_compute_metrics``. - grayscale_levels: Quantisation level count from the + display_levels: Quantisation level count from the display config. Returns: Bar width in pixels. """ - if grayscale_levels <= 2: + if display_levels <= 2: return max(10, m.left_bar * 3) return m.left_bar diff --git a/custom_components/eink_dashboard/strings.json b/custom_components/eink_dashboard/strings.json index 4ed1b40..7b4ec69 100644 --- a/custom_components/eink_dashboard/strings.json +++ b/custom_components/eink_dashboard/strings.json @@ -144,10 +144,12 @@ "description": "{optimize_note}", "data": { "update_interval": "Update interval (seconds)", - "optimize": "E-ink optimization" + "optimize": "E-ink optimization", + "display_levels": "Display levels" }, "data_description": { - "optimize": "Apply autocontrast and quantization for e-ink displays" + "optimize": "Apply autocontrast and quantization for e-ink displays", + "display_levels": "Number of shades or colors the display can reproduce (fewer = higher contrast, more banding)" }, "sections": { "advanced_section": { @@ -155,14 +157,12 @@ "data": { "dither_algorithm": "Dither algorithm", "measured_palette": "Measured palette", - "grayscale_levels": "Grayscale levels", "exposure": "Exposure", "saturation": "Saturation" }, "data_description": { "dither_algorithm": "Algorithm used for error-diffusion dithering", "measured_palette": "Photographically calibrated palette for your specific display model. Auto uses the idealized palette.", - "grayscale_levels": "Number of grayscale shades (fewer = higher contrast, more banding)", "exposure": "1.0 = unchanged, higher = brighter (0.0–10.0)", "saturation": "1.0 = unchanged, 0.0 = grayscale, higher = more vivid (0.0–10.0)" } @@ -225,6 +225,7 @@ "trmnl_x": "TRMNL X", "trmnl_rgb": "TRMNL RGB", "reterminal_e1001": "reTerminal E1001", + "reterminal_e1002": "reTerminal E1002", "reterminal_e1003": "reTerminal E1003", "custom": "Custom" } diff --git a/custom_components/eink_dashboard/translations/en.json b/custom_components/eink_dashboard/translations/en.json index 0db5c53..0af8f97 100644 --- a/custom_components/eink_dashboard/translations/en.json +++ b/custom_components/eink_dashboard/translations/en.json @@ -144,10 +144,12 @@ "description": "{optimize_note}", "data": { "update_interval": "Update interval (seconds)", - "optimize": "E-ink optimization" + "optimize": "E-ink optimization", + "display_levels": "Display levels" }, "data_description": { - "optimize": "Apply autocontrast and quantization for e-ink displays" + "optimize": "Apply autocontrast and quantization for e-ink displays", + "display_levels": "Number of shades or colors the display can reproduce (fewer = higher contrast, more banding)" }, "sections": { "advanced_section": { @@ -155,14 +157,12 @@ "data": { "dither_algorithm": "Dither algorithm", "measured_palette": "Measured palette", - "grayscale_levels": "Grayscale levels", "exposure": "Exposure", "saturation": "Saturation" }, "data_description": { "dither_algorithm": "Algorithm used for error-diffusion dithering", "measured_palette": "Photographically calibrated palette for your specific display model. Auto uses the idealized palette.", - "grayscale_levels": "Number of grayscale shades (fewer = higher contrast, more banding)", "exposure": "1.0 = unchanged, higher = brighter (0.0–10.0)", "saturation": "1.0 = unchanged, 0.0 = grayscale, higher = more vivid (0.0–10.0)" } @@ -225,6 +225,7 @@ "trmnl_x": "TRMNL X", "trmnl_rgb": "TRMNL RGB", "reterminal_e1001": "reTerminal E1001", + "reterminal_e1002": "reTerminal E1002", "reterminal_e1003": "reTerminal E1003", "custom": "Custom" } diff --git a/custom_components/eink_dashboard/widgets/_helpers.py b/custom_components/eink_dashboard/widgets/_helpers.py index 8d2eeec..a5e7f79 100644 --- a/custom_components/eink_dashboard/widgets/_helpers.py +++ b/custom_components/eink_dashboard/widgets/_helpers.py @@ -157,7 +157,7 @@ def _fmt(value: str, config: DisplayConfig) -> str: def _card_insets( m: WidgetMetrics, card_style: str, - grayscale_levels: int, + display_levels: int, ) -> tuple[int, int, int]: """Return (x_off, r_inset, bar_width) for a card container. @@ -171,7 +171,7 @@ def _card_insets( m: ``WidgetMetrics`` dataclass from ``_compute_metrics``. card_style: One of ``"border"``, ``"left_bar"``, or ``"none"`` (or any other value treated as ``"none"``). - grayscale_levels: Display grayscale depth; passed to + display_levels: Display grayscale depth; passed to ``_left_bar_width`` to widen the bar on 2-level displays. @@ -185,7 +185,7 @@ def _card_insets( if card_style == "border": return m.padding, m.padding, 0 if card_style == "left_bar": - bar_w = _left_bar_width(m, grayscale_levels) + bar_w = _left_bar_width(m, display_levels) return bar_w + m.padding, 0, bar_w return 0, 0, 0 @@ -193,7 +193,7 @@ def _card_insets( def _resolve_icon_style( icon_style: str | None, state_val: str = "", - grayscale_levels: int = 16, + display_levels: int = 16, ) -> tuple[bool, bool]: """Resolve icon circle style to outline/no-circle flags. @@ -210,7 +210,7 @@ def _resolve_icon_style( state_val: Entity state string used for active detection when ``icon_style`` is ``None``. Defaults to ``""`` (treated as inactive). - grayscale_levels: Display grayscale depth. Values + display_levels: Display grayscale depth. Values of 2 or fewer force ``"outlined"`` regardless of state. @@ -222,7 +222,7 @@ def _resolve_icon_style( is_active = state_val in _ACTIVE_STATES resolved = ( "outlined" - if grayscale_levels <= 2 + if display_levels <= 2 else ("filled" if is_active else "outlined") ) else: @@ -399,7 +399,7 @@ def _entity_info_context( ``hide_name``, ``icon_style``, ``card_style``, ``bold_value``. config: Display config with ``states`` and - ``grayscale_levels``. + ``display_levels``. section_h: Height of the entity info section in pixels. Entity widget passes ``svg_h``; Sensor widget passes ``entity_h`` (svg_h minus graph_h). @@ -430,7 +430,7 @@ def _entity_info_context( card_style = widget.get("card_style", DEFAULT_CARD_STYLE) value_bold: bool = widget.get("bold_value", False) states = config.get("states", {}) - grayscale_levels = config.get("grayscale_levels", 16) + display_levels = config.get("display_levels", 16) state = states.get(entity_id) if entity_id else None if state is None: @@ -451,7 +451,7 @@ def _entity_info_context( icon_dia = round(header_h * 0.82) icon_inner = icon_dia * 70 // 100 letter_font_sz = icon_dia * 5 // 10 - x_off, r_inset, bar_width = _card_insets(m, card_style, grayscale_levels) + x_off, r_inset, bar_width = _card_insets(m, card_style, display_levels) lpad = m.padding if x_off == 0 else 0 rpad = m.padding if r_inset == 0 else 0 @@ -498,10 +498,10 @@ def _entity_info_context( entity_id, ) icon_outline, icon_no_circle = _resolve_icon_style( - icon_style, state_val, grayscale_levels + icon_style, state_val, display_levels ) # Widen outline stroke on 2-level displays to avoid dithering. - icon_stroke_w = m.border * 3 if grayscale_levels <= 2 else m.border + icon_stroke_w = m.border * 3 if display_levels <= 2 else m.border icon_fill = color_to_hex(COLOR_GRAY) icon_color = ( colors["hex_black"] diff --git a/custom_components/eink_dashboard/widgets/calendar.py b/custom_components/eink_dashboard/widgets/calendar.py index 77b393f..c6e2d48 100644 --- a/custom_components/eink_dashboard/widgets/calendar.py +++ b/custom_components/eink_dashboard/widgets/calendar.py @@ -70,7 +70,7 @@ def _build_calendar_context( ``card_style`` (``"border"``, ``"left_bar"``, or ``"none"``), ``x``, ``w``, ``h``. config: Display config with ``states``, - ``grayscale_levels``, and ``time_format``. + ``display_levels``, and ``time_format``. Returns: Template context dict consumed by @@ -97,7 +97,7 @@ def _build_calendar_context( value_bold: bool = widget.get("bold_value", False) time_format: str = config.get("time_format", "24") states = config.get("states", {}) - grayscale_levels = config.get("grayscale_levels", 16) + display_levels = config.get("display_levels", 16) empty_ctx: dict[str, object] = { "w": svg_w, @@ -136,9 +136,9 @@ def _build_calendar_context( row_h = content_h // num_rows m = _compute_metrics(row_h) - icon_stroke_w = m.border * 3 if grayscale_levels <= 2 else m.border - divider_stroke_w = m.divider * 3 if grayscale_levels <= 2 else m.divider - x_off, r_inset, bar_width = _card_insets(m, card_style, grayscale_levels) + icon_stroke_w = m.border * 3 if display_levels <= 2 else m.border + divider_stroke_w = m.divider * 3 if display_levels <= 2 else m.divider + x_off, r_inset, bar_width = _card_insets(m, card_style, display_levels) lpad = m.padding if x_off == 0 else 0 rpad = m.padding if r_inset == 0 else 0 diff --git a/custom_components/eink_dashboard/widgets/device_battery.py b/custom_components/eink_dashboard/widgets/device_battery.py index 9714fd5..d7757a4 100644 --- a/custom_components/eink_dashboard/widgets/device_battery.py +++ b/custom_components/eink_dashboard/widgets/device_battery.py @@ -57,7 +57,7 @@ def _build_device_battery_context( ``card_style``, ``color``, ``bold_value`` (render the percentage label in bold; default ``False``). config: Display config with ``device_battery_level`` - (int 0–100) and ``grayscale_levels``. + (int 0–100) and ``display_levels``. Returns: Template context dict consumed by @@ -87,7 +87,7 @@ def _build_device_battery_context( pct = max(0, min(100, int(level))) layout = widget.get("layout", "icon") card_style = widget.get("card_style", DEFAULT_CARD_STYLE) - grayscale_levels = config.get("grayscale_levels", 16) + display_levels = config.get("display_levels", 16) color: int = widget.get("color", COLOR_BLACK) value_bold: bool = widget.get("bold_value", False) # Force black below 20% for visual emphasis. @@ -98,7 +98,7 @@ def _build_device_battery_context( label = f"{pct}%" m = _compute_metrics(svg_h) - x_off, r_inset, bar_width = _card_insets(m, card_style, grayscale_levels) + x_off, r_inset, bar_width = _card_insets(m, card_style, display_levels) # Soft-pad when the card provides no inset on that side, # consistent with tile/heading/entities/waste_schedule/weather. lpad = m.padding if x_off == 0 else 0 diff --git a/custom_components/eink_dashboard/widgets/entities.py b/custom_components/eink_dashboard/widgets/entities.py index 0b8bcc9..de647de 100644 --- a/custom_components/eink_dashboard/widgets/entities.py +++ b/custom_components/eink_dashboard/widgets/entities.py @@ -85,7 +85,7 @@ def _build_entities_context( state value in bold; default ``False``), ``card_style``, ``x``, ``w``, ``h``. config: Display config with ``width``, ``states``, and - ``grayscale_levels``. + ``display_levels``. Returns: Template context dict consumed by ``entities.svg.j2``. @@ -106,7 +106,7 @@ def _build_entities_context( value_bold: bool = widget.get("bold_value", False) entity_configs: list = widget.get("entities", []) states = config.get("states", {}) - grayscale_levels = config.get("grayscale_levels", 16) + display_levels = config.get("display_levels", 16) colors = _color_context() # --- Classify rows --- @@ -197,15 +197,15 @@ def _build_entities_context( section_h = max(1, round(row_h * 0.6)) m = _compute_metrics(row_h) - x_off, r_inset, bar_width = _card_insets(m, card_style, grayscale_levels) + x_off, r_inset, bar_width = _card_insets(m, card_style, display_levels) lpad = m.padding if x_off == 0 else 0 rpad = m.padding if r_inset == 0 else 0 # Widen outline stroke on 2-level displays to avoid # dithering. - icon_stroke_w = m.border * 3 if grayscale_levels <= 2 else m.border + icon_stroke_w = m.border * 3 if display_levels <= 2 else m.border # Widen divider lines on 2-level displays (same rationale). - divider_stroke_w = m.divider * 3 if grayscale_levels <= 2 else m.divider + divider_stroke_w = m.divider * 3 if display_levels <= 2 else m.divider icon_fill = color_to_hex(COLOR_GRAY) # Section label font ~46% of row height (matches chip label # ratio). @@ -270,7 +270,7 @@ def _build_entities_context( ) icon_outline, icon_no_circle = _resolve_icon_style( - icon_style, state_val, grayscale_levels + icon_style, state_val, display_levels ) # Auto-divider: draw after this entity row only diff --git a/custom_components/eink_dashboard/widgets/entity.py b/custom_components/eink_dashboard/widgets/entity.py index 8144f25..5e8c82b 100644 --- a/custom_components/eink_dashboard/widgets/entity.py +++ b/custom_components/eink_dashboard/widgets/entity.py @@ -39,7 +39,7 @@ def _build_entity_context( based on entity state when ``icon_style`` is omitted: - ``"filled"`` — gray-filled circle (default for active states - when ``grayscale_levels > 2``). + when ``display_levels > 2``). - ``"outlined"`` — white circle with black stroke (default for inactive states and all 2-level displays). - ``"none"`` — no circle; icon glyph rendered without @@ -62,7 +62,7 @@ def _build_entity_context( default ``False``), ``card_style``, ``x``, ``w``, ``h``. config: Display config with ``width``, ``states``, and - ``grayscale_levels``. + ``display_levels``. Returns: Template context dict consumed by ``entity.svg.j2``. diff --git a/custom_components/eink_dashboard/widgets/gauge.py b/custom_components/eink_dashboard/widgets/gauge.py index 0858409..f673819 100644 --- a/custom_components/eink_dashboard/widgets/gauge.py +++ b/custom_components/eink_dashboard/widgets/gauge.py @@ -203,7 +203,7 @@ def _build_gauge_context( of medium weight; default ``False``), ``card_style``, ``segments``, ``x``, ``w``, ``h``. config: Display config with ``width``, ``states``, and - ``grayscale_levels``. + ``display_levels``. Returns: Template context dict consumed by ``gauge.svg.j2``. @@ -220,7 +220,7 @@ def _build_gauge_context( entity_id: str = widget.get("entity", "") states = config.get("states", {}) - grayscale_levels = config.get("grayscale_levels", 16) + display_levels = config.get("display_levels", 16) card_style: str = widget.get("card_style", DEFAULT_CARD_STYLE) gauge_type: str = widget.get("gauge_type", "standard") header_position: str = widget.get("header_position", "bottom") @@ -285,7 +285,7 @@ def _build_gauge_context( # Use DEFAULT_ROW_H for consistent border proportions across # widget sizes (gauge has no natural row_h concept). m = _compute_metrics(DEFAULT_ROW_H) - x_off, r_inset, bar_width = _card_insets(m, card_style, grayscale_levels) + x_off, r_inset, bar_width = _card_insets(m, card_style, display_levels) # --- Gauge geometry --- # Name label: height proportional to the total widget height. @@ -460,7 +460,7 @@ def _build_gauge_context( "h": h, "has_entity": True, "card_style": card_style, - "grayscale_levels": grayscale_levels, + "display_levels": display_levels, "bar_width": bar_width, "x_off": x_off, "r_inset": r_inset, diff --git a/custom_components/eink_dashboard/widgets/graph.py b/custom_components/eink_dashboard/widgets/graph.py index 9d68e4a..ba7914b 100644 --- a/custom_components/eink_dashboard/widgets/graph.py +++ b/custom_components/eink_dashboard/widgets/graph.py @@ -76,7 +76,7 @@ def _rgb_hex_to_grayscale( hex_color: str, - grayscale_levels: int, + display_levels: int, ) -> str: """Convert an RGB hex color string to a grayscale hex string. @@ -89,7 +89,7 @@ def _rgb_hex_to_grayscale( hex_color: CSS hex color string, e.g. ``"#ff0000"``. Must start with ``#`` followed by exactly six hex digits. Values that do not match this form are treated as black. - grayscale_levels: Number of distinct gray levels on the + display_levels: Number of distinct gray levels on the display. ``2`` produces only black or white; higher values quantize to the nearest available step. @@ -108,11 +108,11 @@ def _rgb_hex_to_grayscale( return color_to_hex(COLOR_BLACK) # ITU-R BT.601 luminance coefficients. gray = round(0.299 * r + 0.587 * g + 0.114 * b) - if grayscale_levels <= 2: + if display_levels <= 2: # Hard threshold at mid-gray. return color_to_hex(0 if gray < 128 else 255) # Quantize to the nearest available step. - steps = grayscale_levels - 1 + steps = display_levels - 1 quantized = round(gray / 255 * steps) * 255 // steps return color_to_hex(quantized) @@ -132,7 +132,7 @@ def _shade_to_hex(shade: str) -> str: def _resolve_threshold_color( entry: dict[str, object], - grayscale_levels: int, + display_levels: int, ) -> str: """Resolve the final hex color for one threshold entry. @@ -142,7 +142,7 @@ def _resolve_threshold_color( Args: entry: Threshold dict with optional ``"shade"`` and ``"color"`` keys. - grayscale_levels: Display grayscale depth, used when mapping + display_levels: Display grayscale depth, used when mapping RGB colors to grayscale. Returns: @@ -153,7 +153,7 @@ def _resolve_threshold_color( return _shade_to_hex(shade) color = str(entry.get("color", "")) if color: - return _rgb_hex_to_grayscale(color, grayscale_levels) + return _rgb_hex_to_grayscale(color, display_levels) return color_to_hex(COLOR_BLACK) @@ -184,7 +184,7 @@ def _threshold_gradient_stops( transition: str, y_min: float, y_max: float, - grayscale_levels: int, + display_levels: int, ) -> list[dict[str, str]]: """Compute SVG linearGradient stop entries for color thresholds. @@ -203,7 +203,7 @@ def _threshold_gradient_stops( transition: ``"smooth"`` or ``"hard"``. y_min: Y-axis lower bound (bottom of graph area). y_max: Y-axis upper bound (top of graph area). - grayscale_levels: Display grayscale depth for color mapping. + display_levels: Display grayscale depth for color mapping. Returns: List of ``{"offset": "XX.XX%", "color": "#hex"}`` dicts, @@ -218,9 +218,7 @@ def _offset(val: float) -> str: pct = max(0.0, min(100.0, pct)) return f"{pct:.2f}%" - colors = [ - _resolve_threshold_color(t, grayscale_levels) for t in thresholds - ] + colors = [_resolve_threshold_color(t, display_levels) for t in thresholds] if transition == "hard": # Descending by value so we build stops top → bottom. @@ -251,7 +249,7 @@ def _offset(val: float) -> str: def _bar_threshold_fill( value: float, thresholds: list[dict[str, object]], - grayscale_levels: int, + display_levels: int, ) -> str: """Resolve the threshold fill color for a single bar value. @@ -261,7 +259,7 @@ def _bar_threshold_fill( Args: value: The bar's data value. thresholds: Sorted ascending by ``"value"``. - grayscale_levels: Display grayscale depth for color mapping. + display_levels: Display grayscale depth for color mapping. Returns: Resolved ``#rrggbb`` hex string for the bar's fill. @@ -272,7 +270,7 @@ def _bar_threshold_fill( chosen = t else: break - return _resolve_threshold_color(chosen, grayscale_levels) + return _resolve_threshold_color(chosen, display_levels) def _normalize_thresholds( @@ -594,7 +592,7 @@ def _fix_header_layout( config: DisplayConfig, header_h: int, svg_w: int, - grayscale_levels: int, + display_levels: int, ) -> tuple[int, int]: """Override header layout fields in the context from _entity_info_context. @@ -614,7 +612,7 @@ def _fix_header_layout( config: Display config. header_h: Pixel height of the header row. svg_w: Full widget width. - grayscale_levels: Display grayscale depth. + display_levels: Display grayscale depth. Returns: ``(gx1, gx2)`` — left and right graph area pixel edges. @@ -623,7 +621,7 @@ def _fix_header_layout( m_hdr = _compute_metrics(header_h) card_style = str(widget.get("card_style", DEFAULT_CARD_STYLE)) - x_off, r_inset, _bar_w = _card_insets(m_hdr, card_style, grayscale_levels) + x_off, r_inset, _bar_w = _card_insets(m_hdr, card_style, display_levels) lpad = m_hdr.padding if x_off == 0 else 0 rpad = m_hdr.padding if r_inset == 0 else 0 @@ -778,7 +776,7 @@ def _bar_series( gy1: int, gy2: int, thresholds: list[dict[str, object]] | None = None, - grayscale_levels: int = 16, + display_levels: int = 16, ) -> list[dict[str, object]]: """Compute bar rectangles for a bar chart from per-entity points. @@ -810,7 +808,7 @@ def _bar_series( thresholds: Optional sorted ascending threshold list; when non-empty, per-bar threshold fills override entity-level colors. - grayscale_levels: Display grayscale depth for threshold color + display_levels: Display grayscale depth for threshold color mapping. Returns: @@ -880,7 +878,7 @@ def _bar_series( # Per-bar threshold fill overrides entity-level color. if thresholds: bar["bar_fill"] = _bar_threshold_fill( - v, thresholds, grayscale_levels + v, thresholds, display_levels ) bars.append(bar) @@ -914,7 +912,7 @@ def _line_series( show_fill: bool, thresholds: list[dict[str, object]] | None = None, threshold_transition: str = "smooth", - grayscale_levels: int = 16, + display_levels: int = 16, ) -> tuple[list[dict[str, object]], bool]: """Compute SVG line/polyline series dicts from per-entity points. @@ -949,7 +947,7 @@ def _line_series( thresholds: Optional sorted ascending threshold list. When non-empty, gradient stops are computed per entity. threshold_transition: ``"smooth"`` or ``"hard"``. - grayscale_levels: Display grayscale depth for color mapping. + display_levels: Display grayscale depth for color mapping. Returns: Tuple of ``(series, has_any_data)`` where ``series`` is a @@ -1032,7 +1030,7 @@ def _line_series( threshold_transition, y_min_s, y_max_s, - grayscale_levels, + display_levels, ) fill_stops = [ { @@ -1383,7 +1381,7 @@ def _build_graph_context( default ``False``), ``card_style``, ``x``, ``w``, ``h``. config: Display config with ``width``, ``states``, - ``grayscale_levels``, and optionally ``time_format`` + ``display_levels``, and optionally ``time_format`` (``"24"`` or ``"12"``; default ``"24"``). Returns: @@ -1415,7 +1413,7 @@ def _build_graph_context( show_state: bool = bool(widget.get("show_state", True)) show_name: bool = bool(widget.get("show_name", True)) show_icon: bool = bool(widget.get("show_icon", True)) - grayscale_levels = config.get("grayscale_levels", 16) + display_levels = config.get("display_levels", 16) # "line" (default) renders polyline/path; "bar" renders # elements. smoothing and show_fill are ignored in bar mode. graph_type: str = str(widget.get("graph", "line")) @@ -1429,7 +1427,7 @@ def _build_graph_context( threshold_transition: str = str( widget.get("color_thresholds_transition", "smooth") ) - has_thresholds: bool = len(raw_thresholds) >= 2 and grayscale_levels > 2 + has_thresholds: bool = len(raw_thresholds) >= 2 and display_levels > 2 active_thresholds: list[dict[str, object]] = ( raw_thresholds if has_thresholds else [] ) @@ -1483,11 +1481,11 @@ def _build_graph_context( } gx1, gx2 = _fix_header_layout( - ctx, widget, config, header_h, svg_w, grayscale_levels + ctx, widget, config, header_h, svg_w, display_levels ) # Stroke width: user-configured, widened on 2-level displays. - graph_stroke_w = line_width * 2 if grayscale_levels <= 2 else line_width + graph_stroke_w = line_width * 2 if display_levels <= 2 else line_width # Inset graph area by 2× stroke so line stays within bounds. margin = graph_stroke_w * 2 gy1 = header_h + margin @@ -1619,7 +1617,7 @@ def _build_graph_context( grid_y_top = gy1 grid_y_bot = gy2 # Suppress fine gray lines on 2-level (B&W) displays. - show_grid = show_labels_ctx and grayscale_levels > 2 + show_grid = show_labels_ctx and display_levels > 2 # --- Secondary Y-axis labels (shifts gx2 inward) --- show_secondary_labels = False @@ -1676,7 +1674,7 @@ def _build_graph_context( gy1, gy2, thresholds=active_thresholds, - grayscale_levels=grayscale_levels, + display_levels=display_levels, ) has_any_data = any(bool(s["has_data"]) for s in series) else: @@ -1696,7 +1694,7 @@ def _build_graph_context( show_fill, thresholds=active_thresholds, threshold_transition=threshold_transition, - grayscale_levels=grayscale_levels, + display_levels=display_levels, ) return { diff --git a/custom_components/eink_dashboard/widgets/heading.py b/custom_components/eink_dashboard/widgets/heading.py index f6166a9..1a5d682 100644 --- a/custom_components/eink_dashboard/widgets/heading.py +++ b/custom_components/eink_dashboard/widgets/heading.py @@ -91,7 +91,7 @@ def _build_heading_context( dicts), ``card_style``, ``x``, ``w``, ``h``. config: Display config with ``width``, ``states``, and - ``grayscale_levels``. + ``display_levels``. Returns: Template context dict consumed by ``heading.svg.j2``. @@ -116,10 +116,10 @@ def _build_heading_context( raw_badges = widget.get("badges", []) card_style = widget.get("card_style", DEFAULT_CARD_STYLE) states = config.get("states", {}) - grayscale_levels = config.get("grayscale_levels", 16) + display_levels = config.get("display_levels", 16) m = _compute_metrics(svg_h) - x_off, r_inset, bar_width = _card_insets(m, card_style, grayscale_levels) + x_off, r_inset, bar_width = _card_insets(m, card_style, display_levels) # Zero lpad/rpad when card_container already insets that side. lpad = m.padding if x_off == 0 else 0 rpad = m.padding if r_inset == 0 else 0 @@ -134,11 +134,11 @@ def _build_heading_context( # Icon resolution: glyph size depends on circle style. icon_outline, icon_no_circle = _resolve_icon_style( - icon_style, grayscale_levels=grayscale_levels + icon_style, display_levels=display_levels ) # Widen the outline stroke on 2-level displays to avoid # dithering. - icon_stroke_w = m.border * 3 if grayscale_levels <= 2 else m.border + icon_stroke_w = m.border * 3 if display_levels <= 2 else m.border glyph_sz = max(10, font_sz) if icon_no_circle else m.icon_inner icon_svg, _ = _resolve_icon_svg( icon_override, diff --git a/custom_components/eink_dashboard/widgets/sensor.py b/custom_components/eink_dashboard/widgets/sensor.py index 48a8e82..ff45aaa 100644 --- a/custom_components/eink_dashboard/widgets/sensor.py +++ b/custom_components/eink_dashboard/widgets/sensor.py @@ -75,7 +75,7 @@ def _build_sensor_context( ``False``), ``card_style``, ``x``, ``w``, ``h``. config: Display config with ``width``, ``states``, and - ``grayscale_levels``. + ``display_levels``. Returns: Template context dict consumed by ``sensor.svg.j2``. @@ -105,7 +105,7 @@ def _build_sensor_context( limits["min"] = lmin if lmax is not None: limits["max"] = lmax - grayscale_levels = config.get("grayscale_levels", 16) + display_levels = config.get("display_levels", 16) has_graph = graph == "line" hide_fill: bool = widget.get("hide_fill", False) @@ -142,7 +142,7 @@ def _build_sensor_context( # displays. graph_stroke_w = ( DEFAULT_METRICS.border * 2 - if grayscale_levels <= 2 + if display_levels <= 2 else DEFAULT_METRICS.border ) diff --git a/custom_components/eink_dashboard/widgets/separator.py b/custom_components/eink_dashboard/widgets/separator.py index a97c48b..57ad5c8 100644 --- a/custom_components/eink_dashboard/widgets/separator.py +++ b/custom_components/eink_dashboard/widgets/separator.py @@ -39,7 +39,7 @@ def _build_separator_context( pre-computed here so the template needs no conditionals. The ``"bar"`` style widens to 10 px on 2-level displays - (``grayscale_levels <= 2``) so the dithered dot pattern reads + (``display_levels <= 2``) so the dithered dot pattern reads clearly as a separator. Args: @@ -52,7 +52,7 @@ def _build_separator_context( span), ``x`` (default ``PADDING``), ``y`` (default 0). config: Display config with ``width``, ``height``, and - optional ``grayscale_levels`` (default 16). + optional ``display_levels`` (default 16). Returns: Dict consumed by ``separator.svg.j2``: ``w``, ``h``, @@ -62,7 +62,7 @@ def _build_separator_context( y = widget.get("y", 0) direction = widget.get("direction", "horizontal") style = widget.get("style", "line") - grayscale_levels = config.get("grayscale_levels", 16) + display_levels = config.get("display_levels", 16) svg_w = _widget_dim(widget, "w", config["width"] - x) svg_h = _widget_dim(widget, "h", config["height"] - y) @@ -71,7 +71,7 @@ def _build_separator_context( color: int = COLOR_GRAY # Widen bar on 2-level displays so the dithered dot # pattern reads clearly as a separator. - thickness = 10 if grayscale_levels <= 2 else 6 + thickness = 10 if display_levels <= 2 else 6 else: color = COLOR_BLACK thickness = 2 diff --git a/custom_components/eink_dashboard/widgets/tile.py b/custom_components/eink_dashboard/widgets/tile.py index 0a57c97..b243d2d 100644 --- a/custom_components/eink_dashboard/widgets/tile.py +++ b/custom_components/eink_dashboard/widgets/tile.py @@ -80,7 +80,7 @@ def _build_tile_context( black card, white text/icon — as an e-ink "needs attention" signal), ``x``, ``w``, ``h``. config: Display config with ``width``, ``height``, - ``states``, and ``grayscale_levels``. + ``states``, and ``display_levels``. Returns: Template context dict consumed by ``tile.svg.j2``. @@ -111,7 +111,7 @@ def _build_tile_context( icon_style = widget.get("icon_style") card_style = widget.get("card_style", DEFAULT_CARD_STYLE) states = config.get("states", {}) - grayscale_levels = config.get("grayscale_levels", 16) + display_levels = config.get("display_levels", 16) state = states.get(entity_id) if entity_id else None if state is None: @@ -129,7 +129,7 @@ def _build_tile_context( svg_h = _widget_dim(widget, "h", _auto_row_height("", 1)) row_h = svg_h m = _compute_metrics(row_h) - x_off, r_inset, bar_width = _card_insets(m, card_style, grayscale_levels) + x_off, r_inset, bar_width = _card_insets(m, card_style, display_levels) # Zero lpad/rpad when card_container already insets that # side. lpad = m.padding if x_off == 0 else 0 @@ -178,14 +178,14 @@ def _build_tile_context( entity_id, ) icon_outline, icon_no_circle = _resolve_icon_style( - icon_style, state_val, grayscale_levels + icon_style, state_val, display_levels ) # Filled style always uses gray; state is conveyed by # icon_style (filled vs outlined), not fill colour. icon_fill = color_to_hex(COLOR_GRAY) # Widen the outline stroke on 2-level displays to avoid # dithering. - icon_stroke_w = m.border * 3 if grayscale_levels <= 2 else m.border + icon_stroke_w = m.border * 3 if display_levels <= 2 else m.border # Inverted "needs attention" signal: same condition format and # evaluator as `visibility`, but drives a solid black card with diff --git a/custom_components/eink_dashboard/widgets/waste_schedule.py b/custom_components/eink_dashboard/widgets/waste_schedule.py index 887e2e1..f3d747f 100644 --- a/custom_components/eink_dashboard/widgets/waste_schedule.py +++ b/custom_components/eink_dashboard/widgets/waste_schedule.py @@ -78,7 +78,7 @@ def _build_waste_schedule_context( ``False``), ``card_style``, ``title``, ``x``, ``w``, ``h``. config: Display config with ``states`` (entity ID → - state dict) and ``grayscale_levels``. + state dict) and ``display_levels``. Returns: Template context dict consumed by @@ -105,7 +105,7 @@ def _build_waste_schedule_context( show_all: bool = bool(widget.get("show_all", False)) value_bold: bool = widget.get("bold_value", False) states = config.get("states", {}) - grayscale_levels = config.get("grayscale_levels", 16) + display_levels = config.get("display_levels", 16) empty_ctx: dict[str, object] = { "w": svg_w, @@ -169,9 +169,9 @@ def _build_waste_schedule_context( row_h = content_h // num_display_rows m = _compute_metrics(row_h) - icon_stroke_w = m.border * 3 if grayscale_levels <= 2 else m.border - divider_stroke_w = m.divider * 3 if grayscale_levels <= 2 else m.divider - x_off, r_inset, bar_width = _card_insets(m, card_style, grayscale_levels) + icon_stroke_w = m.border * 3 if display_levels <= 2 else m.border + divider_stroke_w = m.divider * 3 if display_levels <= 2 else m.divider + x_off, r_inset, bar_width = _card_insets(m, card_style, display_levels) lpad = m.padding if x_off == 0 else 0 rpad = m.padding if r_inset == 0 else 0 diff --git a/custom_components/eink_dashboard/widgets/weather.py b/custom_components/eink_dashboard/widgets/weather.py index 3ad9d90..afb7473 100644 --- a/custom_components/eink_dashboard/widgets/weather.py +++ b/custom_components/eink_dashboard/widgets/weather.py @@ -180,7 +180,7 @@ def _build_weather_context( ``forecast_days``, ``card_style``, ``temperature_entity``, ``humidity_entity``. config: Display config with ``width``, ``height``, - ``states``, ``grayscale_levels``. + ``states``, ``display_levels``. Returns: Template context dict consumed by ``weather.svg.j2``. @@ -220,7 +220,7 @@ def _build_weather_context( font_size = widget.get("font_size", FONT_SIZE_WEATHER) forecast_days = widget.get("forecast_days", 5) card_style = widget.get("card_style", DEFAULT_CARD_STYLE) - grayscale_levels = config.get("grayscale_levels", 16) + display_levels = config.get("display_levels", 16) scale = font_size / FONT_SIZE_WEATHER @@ -324,7 +324,7 @@ def _build_weather_context( # configured. svg_h = _widget_dim(widget, "h", total_h) - x_off, r_inset, bar_width = _card_insets(m, card_style, grayscale_levels) + x_off, r_inset, bar_width = _card_insets(m, card_style, display_levels) # Soft-pad when the card provides no inset on that side, # consistent with tile/heading/entities/waste_schedule. lpad = m.padding if x_off == 0 else 0 diff --git a/scripts/bench_render.py b/scripts/bench_render.py index 8397cd1..febef73 100755 --- a/scripts/bench_render.py +++ b/scripts/bench_render.py @@ -435,7 +435,7 @@ def _build_config( return { "width": preset.width, "height": preset.height, - "grayscale_levels": preset.grayscale_levels, + "display_levels": preset.display_levels, "optimize": preset.optimize, "states": states, **extra, @@ -681,7 +681,7 @@ def main() -> None: peak_rss_kb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss _report( f"{args.device} ({preset.width}x{preset.height}," - f" {preset.grayscale_levels}-level)" + f" {preset.display_levels}-level)" f" | {label}" f" | {args.iterations} iter", results, diff --git a/scripts/design_tool.py b/scripts/design_tool.py index 48c1e4c..8efef5f 100755 --- a/scripts/design_tool.py +++ b/scripts/design_tool.py @@ -338,7 +338,7 @@ def _render_optimized(png_bytes: bytes, config: dict) -> bytes:
Widget: {widget_type} | Device: {device_name} - ({width}×{height}, {grayscale_levels}-level) | + ({width}×{height}, {display_levels}-level) | Port: {port}
@@ -957,7 +957,7 @@ def _serve_html(self) -> None: device_name=st.preset.label, width=st.preset.width, height=st.preset.height, - grayscale_levels=st.preset.grayscale_levels, + display_levels=st.preset.display_levels, port=st.port, initial_data=initial, ) @@ -1442,7 +1442,7 @@ def main() -> None: preset.label, preset.width, preset.height, - preset.grayscale_levels, + preset.display_levels, ) _logger.info("Press Ctrl+C to stop.") diff --git a/scripts/render_weather_example.py b/scripts/render_weather_example.py index 0274a5d..363ca34 100755 --- a/scripts/render_weather_example.py +++ b/scripts/render_weather_example.py @@ -102,7 +102,7 @@ { "width": 800, "height": 480, - "grayscale_levels": 2, + "display_levels": 2, "states": WEATHER_STATE, }, ), @@ -111,7 +111,7 @@ { "width": 758, "height": 1024, - "grayscale_levels": 16, + "display_levels": 16, "states": WEATHER_STATE, }, ), @@ -127,5 +127,5 @@ out = output_dir / filename out.write_bytes(png) w, h = config["width"], config["height"] - lvls = config["grayscale_levels"] + lvls = config["display_levels"] print(f"{out} ({w}x{h}, {lvls}-level, {len(png)} bytes)") diff --git a/tests/helpers.py b/tests/helpers.py index 6f8a046..fc9ea8c 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -306,7 +306,7 @@ def _right_icon_ring_region( w: int, h: int, card_style: str = "none", - grayscale_levels: int = 16, + display_levels: int = 16, ) -> tuple[int, int, int, int, int, int]: """Return the icon ring region for right-aligned icon style tests. @@ -323,7 +323,7 @@ def _right_icon_ring_region( h: Total widget height in pixels; header_h (40%) is computed internally. card_style: Card decoration style string. - grayscale_levels: Display grayscale depth. + display_levels: Display grayscale depth. Returns: Six-tuple (icon_cx, icon_cy, ring_x1, ring_y1, ring_x2, @@ -331,7 +331,7 @@ def _right_icon_ring_region( """ header_h = round(h * 0.40) m = _compute_metrics(header_h) - _, r_inset, _ = _card_insets(m, card_style, grayscale_levels) + _, r_inset, _ = _card_insets(m, card_style, display_levels) rpad = m.padding if r_inset == 0 else 0 # Mirror the enlarged icon ratios from _entity_info_context(). icon_dia = round(header_h * 0.82) @@ -341,7 +341,7 @@ def _right_icon_ring_region( icon_cy = header_h // 2 # Mirror the context builder's icon_stroke_w so the ring region # starts past the inner edge of a widened stroke on 2-level. - icon_stroke_w = m.border * 3 if grayscale_levels <= 2 else m.border + icon_stroke_w = m.border * 3 if display_levels <= 2 else m.border ring_y1 = icon_cy - icon_r + icon_stroke_w // 2 + 3 ring_y2 = icon_cy - icon_inner // 2 - 1 ring_x1 = icon_cx - icon_r // 2 + 3 diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 58ed52a..f6b2a91 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -110,7 +110,7 @@ async def test_kindle_creates_entry(self, hass: HomeAssistant) -> None: assert opts["height"] == 1024 assert opts["rotation"] == 0 assert opts["optimize"] is True - assert opts["grayscale_levels"] == 16 + assert opts["display_levels"] == 16 assert opts["dither_algorithm"] == "floyd_steinberg" assert opts["update_interval"] == 60 assert opts["webhook_urls"] == [] @@ -177,7 +177,7 @@ async def test_reterminal_e1001_creates_entry( assert result["type"] is FlowResultType.CREATE_ENTRY opts = result["options"] assert opts["optimize"] is False - assert opts["grayscale_levels"] == 4 + assert opts["display_levels"] == 4 # Native landscape device in landscape orientation: no rotation. assert opts["width"] == 800 assert opts["height"] == 480 @@ -201,9 +201,33 @@ async def test_reterminal_e1003_creates_entry( assert result["type"] is FlowResultType.CREATE_ENTRY opts = result["options"] assert opts["optimize"] is False - assert opts["grayscale_levels"] == 16 + assert opts["display_levels"] == 16 + # Panel is native landscape (OpenDisplay firmware), so portrait + # orientation is achieved by rotating the rendered canvas. assert opts["width"] == 1404 assert opts["height"] == 1872 + assert opts["rotation"] == 90 + + async def test_reterminal_e1003_landscape_creates_entry( + self, hass: HomeAssistant + ) -> None: + # Native landscape device in landscape orientation (the + # default): no rotation, and dimensions match the panel's + # native landscape buffer. + flow = await _make_config_flow(hass) + result = await flow.async_step_user( + { + "name": "Office", + "device_model": "reterminal_e1003", + "orientation": "landscape", + "update_interval": 60, + } + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + opts = result["options"] + assert opts["width"] == 1872 + assert opts["height"] == 1404 assert opts["rotation"] == 0 async def test_screen_portion_shows_form_for_trmnl( @@ -294,7 +318,7 @@ async def test_trmnl_og_dimensions(self, hass: HomeAssistant) -> None: assert flow._data["width"] == 800 assert flow._data["height"] == 480 assert flow._data["rotation"] == 0 - assert flow._data["grayscale_levels"] == 2 + assert flow._data["display_levels"] == 2 async def test_trmnl_portrait_rotation(self, hass: HomeAssistant) -> None: # TRMNL in portrait orientation swaps dimensions and sets @@ -692,8 +716,9 @@ async def test_display_settings_shows_form( async def test_display_settings_saves_values( self, hass: HomeAssistant ) -> None: - # Submitting update_interval with defaults in advanced_section - # merges all values into the stored options. + # Submitting update_interval merges all values into the stored + # options. advanced_section is absent from the schema because + # optimize is off, so it must not be submitted. flow = await _make_options_flow( hass, { @@ -704,7 +729,7 @@ async def test_display_settings_saves_values( }, ) result = await flow.async_step_display_settings( - {"update_interval": 120, "advanced_section": {}} + {"update_interval": 120} ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -716,15 +741,18 @@ async def test_display_settings_saves_optimize_values( self, hass: HomeAssistant ) -> None: # Submitting optimize settings via the advanced_section persists - # them. - flow = await _make_options_flow(hass, {"update_interval": 60}) + # them. advanced_section is only present in the schema once + # optimize is already enabled in the stored options. + flow = await _make_options_flow( + hass, {"update_interval": 60, "optimize": True} + ) result = await flow.async_step_display_settings( { "update_interval": 60, "optimize": True, + "display_levels": 2, "advanced_section": { "dither_algorithm": "floyd_steinberg", - "grayscale_levels": "2", "exposure": "1.5", "saturation": "0.8", }, @@ -733,7 +761,7 @@ async def test_display_settings_saves_optimize_values( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"]["optimize"] is True - assert result["data"]["grayscale_levels"] == 2 + assert result["data"]["display_levels"] == 2 assert result["data"]["exposure"] == 1.5 assert result["data"]["saturation"] == 0.8 @@ -741,36 +769,37 @@ async def test_display_settings_hides_exposure_at_256_levels( self, hass: HomeAssistant, ) -> None: - # When grayscale_levels==256 is saved in opts, exposure/saturation - # are excluded from the schema because dither_image() is not - # called on the 256-level passthrough path. Submitting those keys - # raises vol.Invalid. + # When display_levels==256 is saved in opts, exposure/saturation + # are excluded from the advanced_section schema because + # dither_image() is not called on the 256-level passthrough + # path. Submitting those keys raises vol.Invalid. flow = await _make_options_flow( - hass, {"update_interval": 60, "grayscale_levels": 256} + hass, + { + "update_interval": 60, + "optimize": True, + "display_levels": 256, + }, ) with pytest.raises(vol.Invalid): await flow.async_step_display_settings( { "update_interval": 60, - "advanced_section": { - "grayscale_levels": "256", - "exposure": "1.5", - }, + "optimize": True, + "display_levels": 256, + "advanced_section": {"exposure": "1.5"}, } ) - async def test_display_settings_rejects_invalid_grayscale_levels( + async def test_display_settings_rejects_invalid_display_levels( self, hass: HomeAssistant, ) -> None: - # An invalid grayscale_levels value raises vol.Invalid. + # An invalid display_levels value raises vol.Invalid. flow = await _make_options_flow(hass, {"update_interval": 60}) with pytest.raises(vol.Invalid): await flow.async_step_display_settings( - { - "update_interval": 60, - "advanced_section": {"grayscale_levels": 7}, - } + {"update_interval": 60, "display_levels": 7} ) async def test_display_settings_shows_optimize_note_for_reterminal( @@ -815,11 +844,14 @@ async def test_display_settings_no_optimize_note_for_legacy_entry( placeholders = result.get("description_placeholders", {}) assert placeholders.get("optimize_note", "") == "" - async def test_display_settings_has_advanced_section( + async def test_display_settings_has_advanced_section_when_optimize_on( self, hass: HomeAssistant ) -> None: - # The display_settings form includes an advanced_section field. - flow = await _make_options_flow(hass, {"update_interval": 60}) + # The advanced_section field is present once optimize is + # already enabled in the stored options. + flow = await _make_options_flow( + hass, {"update_interval": 60, "optimize": True} + ) result = await flow.async_step_display_settings(None) field_names = { @@ -829,18 +861,152 @@ async def test_display_settings_has_advanced_section( } assert "advanced_section" in field_names + async def test_display_settings_hides_advanced_section_when_off( + self, hass: HomeAssistant + ) -> None: + # The advanced_section is omitted from the schema when optimize + # is disabled, since its fields (dither/palette/exposure/ + # saturation) have no effect until optimize is turned on. + flow = await _make_options_flow( + hass, {"update_interval": 60, "optimize": False} + ) + result = await flow.async_step_display_settings(None) + + field_names = { + k.schema + for k in result["data_schema"].schema + if hasattr(k, "schema") + } + assert "advanced_section" not in field_names + + async def test_display_settings_has_top_level_display_levels( + self, hass: HomeAssistant + ) -> None: + # display_levels affects widget rendering regardless of + # optimize, so it is a top-level field, shown even when + # optimize (and therefore advanced_section) is off. + flow = await _make_options_flow( + hass, {"update_interval": 60, "optimize": False} + ) + result = await flow.async_step_display_settings(None) + + field_names = { + k.schema + for k in result["data_schema"].schema + if hasattr(k, "schema") + } + assert "display_levels" in field_names + + async def test_display_settings_defaults_to_device_preset_levels( + self, hass: HomeAssistant + ) -> None: + # When display_levels has never been saved, the field default + # should come from the device's preset instead of the global + # DEFAULT_DISPLAY_LEVELS constant, so devices like the + # reterminal_e1002 (256 levels) show their own capability + # pre-selected rather than an unrelated fallback. + flow = await _make_options_flow( + hass, + { + "update_interval": 60, + "device_model": "reterminal_e1002", + }, + ) + result = await flow.async_step_display_settings(None) + + markers = { + k.schema: k + for k in result["data_schema"].schema + if hasattr(k, "schema") + } + assert markers["display_levels"].default() == 256 + + async def test_display_settings_prefers_stored_levels_over_preset( + self, hass: HomeAssistant + ) -> None: + # A previously saved display_levels value always wins over the + # device preset's default. + flow = await _make_options_flow( + hass, + { + "update_interval": 60, + "device_model": "reterminal_e1002", + "display_levels": 16, + }, + ) + result = await flow.async_step_display_settings(None) + + markers = { + k.schema: k + for k in result["data_schema"].schema + if hasattr(k, "schema") + } + assert markers["display_levels"].default() == 16 + + async def test_display_settings_saves_optimize_toggle_on( + self, hass: HomeAssistant + ) -> None: + # Turning optimize on in the same submission where it was + # previously off must succeed even though advanced_section is + # absent from the schema built from the stale stored value. + flow = await _make_options_flow( + hass, {"update_interval": 60, "optimize": False} + ) + result = await flow.async_step_display_settings( + { + "update_interval": 60, + "optimize": True, + "display_levels": 16, + } + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"]["optimize"] is True + assert "dither_algorithm" not in result["data"] + assert "exposure" not in result["data"] + assert "saturation" not in result["data"] + + async def test_display_settings_preserves_advanced_when_optimize_off( + self, hass: HomeAssistant + ) -> None: + # Saving with optimize off hides advanced_section from the + # schema, so previously-stored advanced values must survive the + # {**opts, **validated, **section} merge rather than being + # dropped or reset to defaults. + flow = await _make_options_flow( + hass, + { + "update_interval": 60, + "optimize": False, + "dither_algorithm": "atkinson", + "exposure": 2.0, + "saturation": 1.5, + }, + ) + result = await flow.async_step_display_settings( + {"update_interval": 120, "display_levels": 16} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"]["update_interval"] == 120 + assert result["data"]["dither_algorithm"] == "atkinson" + assert result["data"]["exposure"] == 2.0 + assert result["data"]["saturation"] == 1.5 + async def test_display_settings_saves_dither_algorithm( self, hass: HomeAssistant ) -> None: # Submitting dither_algorithm via advanced_section persists it. - flow = await _make_options_flow(hass, {"update_interval": 60}) + flow = await _make_options_flow( + hass, {"update_interval": 60, "optimize": True} + ) result = await flow.async_step_display_settings( { "update_interval": 60, "optimize": True, + "display_levels": 16, "advanced_section": { "dither_algorithm": "atkinson", - "grayscale_levels": "16", "exposure": "1.0", "saturation": "1.0", }, @@ -856,9 +1022,15 @@ async def test_display_settings_default_dither_algorithm( ) -> None: # Legacy entries without dither_algorithm default to # floyd_steinberg. - flow = await _make_options_flow(hass, {"update_interval": 60}) + flow = await _make_options_flow( + hass, {"update_interval": 60, "optimize": True} + ) result = await flow.async_step_display_settings( - {"update_interval": 60, "advanced_section": {}} + { + "update_interval": 60, + "optimize": True, + "advanced_section": {}, + } ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -868,15 +1040,17 @@ async def test_display_settings_saves_measured_palette( self, hass: HomeAssistant ) -> None: # Submitting measured_palette via advanced_section persists it. - flow = await _make_options_flow(hass, {"update_interval": 60}) + flow = await _make_options_flow( + hass, {"update_interval": 60, "optimize": True} + ) result = await flow.async_step_display_settings( { "update_interval": 60, "optimize": True, + "display_levels": 16, "advanced_section": { "dither_algorithm": "floyd_steinberg", "measured_palette": "spectra_7_3_6color", - "grayscale_levels": "16", "exposure": "1.0", "saturation": "1.0", }, @@ -890,9 +1064,15 @@ async def test_display_settings_default_measured_palette( self, hass: HomeAssistant ) -> None: # Legacy entries without measured_palette default to "auto". - flow = await _make_options_flow(hass, {"update_interval": 60}) + flow = await _make_options_flow( + hass, {"update_interval": 60, "optimize": True} + ) result = await flow.async_step_display_settings( - {"update_interval": 60, "advanced_section": {}} + { + "update_interval": 60, + "optimize": True, + "advanced_section": {}, + } ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -936,7 +1116,7 @@ async def test_device_settings_preset_recomputes_dimensions( assert result["data"]["height"] == 1448 assert result["data"]["rotation"] == 0 assert result["data"]["optimize"] is True - assert result["data"]["grayscale_levels"] == 16 + assert result["data"]["display_levels"] == 16 assert result["data"]["dither_algorithm"] == "floyd_steinberg" assert result["data"]["webhook_urls"] == [] @@ -1453,7 +1633,7 @@ async def test_options_custom_resolution_saves( assert result["data"]["height"] == 800 assert result["data"]["rotation"] == 0 assert result["data"]["optimize"] is False - assert result["data"]["grayscale_levels"] == 16 + assert result["data"]["display_levels"] == 16 assert result["data"]["webhook_urls"] == [] async def test_options_custom_resolution_preserves_area( @@ -1524,7 +1704,7 @@ async def test_device_settings_custom_to_preset( assert result["data"]["height"] == 1448 assert result["data"]["rotation"] == 0 assert result["data"]["optimize"] is True - assert result["data"]["grayscale_levels"] == 16 + assert result["data"]["display_levels"] == 16 async def test_device_settings_custom_stays_custom_skips_resolution( self, @@ -1637,10 +1817,7 @@ async def test_display_settings_persists_entry_options( result = await hass.config_entries.options.async_configure( result["flow_id"], - { - "update_interval": 120, - "advanced_section": {}, - }, + {"update_interval": 120}, ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -1654,7 +1831,8 @@ async def test_migration_removes_sharpness_contrast( self, hass: HomeAssistant ) -> None: # Migration from minor_version<2 removes sharpness/contrast and - # adds exposure/saturation with their defaults. + # adds exposure/saturation with their defaults. The entry then + # cascades through the 2->3 and 3->4 migrations too, ending at 4. from custom_components.eink_dashboard import async_migrate_entry entry = MockConfigEntry( @@ -1673,7 +1851,7 @@ async def test_migration_removes_sharpness_contrast( result = await async_migrate_entry(hass, entry) assert result is True - assert entry.minor_version == 2 + assert entry.minor_version == 4 assert "sharpness" not in entry.options assert "contrast" not in entry.options assert entry.options["exposure"] == 1.0 @@ -1692,7 +1870,7 @@ async def test_migration_preserves_other_options( options={ "update_interval": 120, "optimize": True, - "grayscale_levels": 4, + "display_levels": 4, "sharpness": 1.0, "contrast": 1.0, }, @@ -1703,23 +1881,144 @@ async def test_migration_preserves_other_options( assert entry.options["update_interval"] == 120 assert entry.options["optimize"] is True - assert entry.options["grayscale_levels"] == 4 + assert entry.options["display_levels"] == 4 + + async def test_migration_renames_grayscale_levels( + self, hass: HomeAssistant + ) -> None: + # Migration from minor_version<3 renames the stored + # grayscale_levels option key to display_levels. The entry + # then cascades through the 3->4 migration too, ending at 4. + from custom_components.eink_dashboard import async_migrate_entry + + entry = MockConfigEntry( + domain=DOMAIN, + minor_version=2, + entry_id="test-entry", + options={ + "update_interval": 60, + "exposure": 1.0, + "saturation": 1.0, + "grayscale_levels": 4, + }, + ) + entry.add_to_hass(hass) + + result = await async_migrate_entry(hass, entry) - async def test_migration_skipped_when_already_at_minor_version_2( + assert result is True + assert entry.minor_version == 4 + assert "grayscale_levels" not in entry.options + assert entry.options["display_levels"] == 4 + + async def test_migration_fixes_reterminal_e1003_landscape_rotation( self, hass: HomeAssistant, ) -> None: - # Entries already at minor_version=2 are not migrated again. + # Migration from minor_version<4 recomputes width/height/ + # rotation for reterminal_e1003 entries created under the old, + # buggy preset. A landscape entry had a stale rotation=90 + # baked in; the fix clears it to 0. from custom_components.eink_dashboard import async_migrate_entry entry = MockConfigEntry( domain=DOMAIN, - minor_version=2, + minor_version=3, + entry_id="test-entry", + options={ + "device_model": "reterminal_e1003", + "orientation": "landscape", + "width": 1872, + "height": 1404, + "rotation": 90, + }, + ) + entry.add_to_hass(hass) + + result = await async_migrate_entry(hass, entry) + + assert result is True + assert entry.minor_version == 4 + assert entry.options["width"] == 1872 + assert entry.options["height"] == 1404 + assert entry.options["rotation"] == 0 + + async def test_migration_fixes_reterminal_e1003_portrait_rotation( + self, + hass: HomeAssistant, + ) -> None: + # A portrait reterminal_e1003 entry had a stale rotation=0 + # baked in under the old preset; the fix sets it to 90. + from custom_components.eink_dashboard import async_migrate_entry + + entry = MockConfigEntry( + domain=DOMAIN, + minor_version=3, + entry_id="test-entry", + options={ + "device_model": "reterminal_e1003", + "orientation": "portrait", + "width": 1404, + "height": 1872, + "rotation": 0, + }, + ) + entry.add_to_hass(hass) + + result = await async_migrate_entry(hass, entry) + + assert result is True + assert entry.minor_version == 4 + assert entry.options["width"] == 1404 + assert entry.options["height"] == 1872 + assert entry.options["rotation"] == 90 + + async def test_migration_skips_non_e1003_devices( + self, + hass: HomeAssistant, + ) -> None: + # Entries for other device models must not have their + # width/height/rotation touched by the 3->4 migration. + from custom_components.eink_dashboard import async_migrate_entry + + entry = MockConfigEntry( + domain=DOMAIN, + minor_version=3, + entry_id="test-entry", + options={ + "device_model": "kindle_pw", + "orientation": "portrait", + "width": 758, + "height": 1024, + "rotation": 0, + }, + ) + entry.add_to_hass(hass) + + result = await async_migrate_entry(hass, entry) + + assert result is True + assert entry.minor_version == 4 + assert entry.options["width"] == 758 + assert entry.options["height"] == 1024 + assert entry.options["rotation"] == 0 + + async def test_migration_skipped_when_already_at_minor_version_4( + self, + hass: HomeAssistant, + ) -> None: + # Entries already at minor_version=4 are not migrated again. + from custom_components.eink_dashboard import async_migrate_entry + + entry = MockConfigEntry( + domain=DOMAIN, + minor_version=4, entry_id="test-entry", options={ "update_interval": 60, "exposure": 1.0, "saturation": 1.0, + "display_levels": 16, }, ) entry.add_to_hass(hass) diff --git a/tests/test_http.py b/tests/test_http.py index d3b8dcd..8f2506d 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -454,7 +454,7 @@ async def test_returns_layout_json( assert body["display"] == { "width": 758, "height": 1024, - "grayscale_levels": 16, + "display_levels": 16, } assert body["device"]["name"] == "Test Dashboard" assert body["device"]["model"] == "kindle_pw" diff --git a/tests/test_image.py b/tests/test_image.py index 7b7b958..fe7b36e 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -454,7 +454,7 @@ async def test_optimize_options_forwarded_to_render( "width": 200, "height": 100, "optimize": True, - "grayscale_levels": 4, + "display_levels": 4, "exposure": 1.5, "saturation": 0.8, } @@ -467,7 +467,7 @@ async def test_optimize_options_forwarded_to_render( await entity._async_refresh(None) config = mock_render.call_args[0][1] assert config["optimize"] is True - assert config["grayscale_levels"] == 4 + assert config["display_levels"] == 4 assert config["exposure"] == 1.5 assert config["saturation"] == 0.8 diff --git a/tests/test_optimize.py b/tests/test_optimize.py index cc9b1b0..cc942ed 100644 --- a/tests/test_optimize.py +++ b/tests/test_optimize.py @@ -57,21 +57,21 @@ def test_size_preserved(self) -> None: def test_quantize_16_colors(self) -> None: img = _gradient() result = optimize_for_eink( - img, {"optimize": True, "grayscale_levels": 16} + img, {"optimize": True, "display_levels": 16} ) assert len(set(result.get_flattened_data())) <= 16 def test_quantize_4_colors(self) -> None: img = _gradient() result = optimize_for_eink( - img, {"optimize": True, "grayscale_levels": 4} + img, {"optimize": True, "display_levels": 4} ) assert len(set(result.get_flattened_data())) <= 4 def test_quantize_2_colors(self) -> None: img = _gradient() result = optimize_for_eink( - img, {"optimize": True, "grayscale_levels": 2} + img, {"optimize": True, "display_levels": 2} ) assert result.mode == "1" assert len(set(result.get_flattened_data())) <= 2 @@ -83,7 +83,7 @@ def test_quantize_2_preserves_content(self) -> None: draw = ImageDraw.Draw(img) draw.rectangle((10, 10, 190, 90), fill=0) result = optimize_for_eink( - img, {"optimize": True, "grayscale_levels": 2} + img, {"optimize": True, "display_levels": 2} ) assert result.mode == "1" dark = sum(1 for b in result.convert("L").tobytes() if b == 0) @@ -95,7 +95,7 @@ def test_256_levels_skips_quantize(self) -> None: assert len(set(img.get_flattened_data())) == 256 result = optimize_for_eink( img, - {"optimize": True, "grayscale_levels": 256}, + {"optimize": True, "display_levels": 256}, ) assert result.mode == "L" assert len(set(result.get_flattened_data())) == 256 @@ -108,7 +108,7 @@ def test_autocontrast_stretches_range(self) -> None: img.putpixel((x, y), 100 + x) result = optimize_for_eink( img, - {"optimize": True, "grayscale_levels": 256}, + {"optimize": True, "display_levels": 256}, ) data = list(result.get_flattened_data()) assert min(data) <= 5 @@ -133,7 +133,7 @@ def _run_with_mock(self, config: dict) -> MagicMock: def test_default_exposure_saturation_passed(self) -> None: # When not configured, dither_image receives the defaults (1.0/1.0). - mock = self._run_with_mock({"optimize": True, "grayscale_levels": 16}) + mock = self._run_with_mock({"optimize": True, "display_levels": 16}) mock.assert_called_once() assert mock.call_args[1]["exposure"] == 1.0 assert mock.call_args[1]["saturation"] == 1.0 @@ -141,7 +141,7 @@ def test_default_exposure_saturation_passed(self) -> None: def test_custom_exposure_passed(self) -> None: # A custom exposure value is forwarded to dither_image. mock = self._run_with_mock( - {"optimize": True, "grayscale_levels": 16, "exposure": 1.5} + {"optimize": True, "display_levels": 16, "exposure": 1.5} ) mock.assert_called_once() assert mock.call_args[1]["exposure"] == 1.5 @@ -149,7 +149,7 @@ def test_custom_exposure_passed(self) -> None: def test_custom_saturation_passed(self) -> None: # A custom saturation value is forwarded to dither_image. mock = self._run_with_mock( - {"optimize": True, "grayscale_levels": 16, "saturation": 0.5} + {"optimize": True, "display_levels": 16, "saturation": 0.5} ) mock.assert_called_once() assert mock.call_args[1]["saturation"] == 0.5 @@ -180,7 +180,7 @@ def test_exposure_saturation_on_color_path(self) -> None: assert mock.call_args[1]["saturation"] == 0.8 def test_grayscale_256_skips_dither_image(self) -> None: - # grayscale_levels=256 is the passthrough path: dither_image() is + # display_levels=256 is the passthrough path: dither_image() is # never called, so exposure/saturation have no effect. img = _gradient() mock = MagicMock() @@ -192,7 +192,7 @@ def test_grayscale_256_skips_dither_image(self) -> None: img, { "optimize": True, - "grayscale_levels": 256, + "display_levels": 256, "exposure": 1.5, "saturation": 0.5, }, @@ -220,7 +220,7 @@ def test_default_uses_floyd_steinberg(self) -> None: # Config without dither_algorithm defaults to Floyd-Steinberg. from epaper_dithering import DitherMode - mock = self._run_with_mock({"optimize": True, "grayscale_levels": 16}) + mock = self._run_with_mock({"optimize": True, "display_levels": 16}) mock.assert_called_once() assert mock.call_args[1]["mode"] is DitherMode.FLOYD_STEINBERG @@ -231,7 +231,7 @@ def test_explicit_floyd_steinberg(self) -> None: mock = self._run_with_mock( { "optimize": True, - "grayscale_levels": 16, + "display_levels": 16, "dither_algorithm": "floyd_steinberg", } ) @@ -245,7 +245,7 @@ def test_atkinson_selected(self) -> None: mock = self._run_with_mock( { "optimize": True, - "grayscale_levels": 16, + "display_levels": 16, "dither_algorithm": "atkinson", } ) @@ -259,7 +259,7 @@ def test_stucki_selected(self) -> None: mock = self._run_with_mock( { "optimize": True, - "grayscale_levels": 16, + "display_levels": 16, "dither_algorithm": "stucki", } ) @@ -273,7 +273,7 @@ def test_burkes_selected(self) -> None: mock = self._run_with_mock( { "optimize": True, - "grayscale_levels": 16, + "display_levels": 16, "dither_algorithm": "burkes", } ) @@ -289,7 +289,7 @@ def test_unknown_algorithm_falls_back_to_floyd_steinberg( mock = self._run_with_mock( { "optimize": True, - "grayscale_levels": 16, + "display_levels": 16, "dither_algorithm": "nonexistent_algo", } ) @@ -358,7 +358,7 @@ def test_render_with_optimize_limits_colors(self) -> None: "width": 200, "height": 100, "optimize": True, - "grayscale_levels": 4, + "display_levels": 4, } widgets = [{"type": "heading", "x": 10, "y": 10, "heading": "Hi"}] png = render_dashboard(widgets, config) @@ -375,7 +375,7 @@ def test_render_2_levels_produces_1bit_png(self) -> None: "width": 200, "height": 100, "optimize": True, - "grayscale_levels": 2, + "display_levels": 2, } widgets = [{"type": "heading", "x": 10, "y": 10, "heading": "Hi"}] png = render_dashboard(widgets, config) @@ -474,10 +474,10 @@ def _run_with_mock( [ { "optimize": True, - "grayscale_levels": 16, + "display_levels": 16, "measured_palette": "auto", }, - {"optimize": True, "grayscale_levels": 16}, + {"optimize": True, "display_levels": 16}, ], ids=["explicit-auto", "absent-key"], ) @@ -525,7 +525,7 @@ def test_measured_palette_with_grayscale(self) -> None: mock = self._run_with_mock( { "optimize": True, - "grayscale_levels": 2, + "display_levels": 2, "measured_palette": "mono_4_26", }, return_colors=2, @@ -543,7 +543,7 @@ def test_unknown_measured_palette_falls_back_to_scheme(self) -> None: mock = self._run_with_mock( { "optimize": True, - "grayscale_levels": 16, + "display_levels": 16, "measured_palette": "nonexistent_palette", } ) @@ -564,14 +564,14 @@ def test_measured_palette_produces_rgb_output(self) -> None: assert result.mode == "RGB" def test_measured_palette_produces_mono_output(self) -> None: - # End-to-end: measured MONO palette with grayscale_levels=2 still + # End-to-end: measured MONO palette with display_levels=2 still # produces mode "1" (binary) output, not "L". img = _gradient() result = optimize_for_eink( img, { "optimize": True, - "grayscale_levels": 2, + "display_levels": 2, "measured_palette": "mono_4_26", }, ) diff --git a/tests/test_render_calendar.py b/tests/test_render_calendar.py index 7de8fac..6a5e861 100644 --- a/tests/test_render_calendar.py +++ b/tests/test_render_calendar.py @@ -266,7 +266,7 @@ class TestRenderCalendar: _DEFAULTS: ClassVar[dict[str, object]] = { "width": 400, "height": 300, - "grayscale_levels": 16, + "display_levels": 16, "time_format": "24", "states": MOCK_CALENDAR_STATES, } @@ -662,7 +662,7 @@ def test_calendar_2level_icon_stroke_widened(self) -> None: ) with patch(_PATCH_NOW, wraps=dt.date) as mock_dt: mock_dt.today.return_value = _TODAY - svg = render_widget_svg(w, self._config(grayscale_levels=2)) + svg = render_widget_svg(w, self._config(display_levels=2)) expected = m.border * 3 assert f'stroke-width="{expected}"' in svg, ( f"2-level stroke-width should be {expected}" @@ -675,7 +675,7 @@ def test_calendar_2level_divider_stroke_widened(self) -> None: w = self._widget(h=2 * DEFAULT_ROW_H, max_events=2) with patch(_PATCH_NOW, wraps=dt.date) as mock_dt: mock_dt.today.return_value = _TODAY - svg = render_widget_svg(w, self._config(grayscale_levels=2)) + svg = render_widget_svg(w, self._config(display_levels=2)) expected = m.divider * 3 assert f'stroke-width="{expected}"' in svg, ( f"2-level divider stroke-width should be {expected}" diff --git a/tests/test_render_entities.py b/tests/test_render_entities.py index 91ad6d8..31f366b 100644 --- a/tests/test_render_entities.py +++ b/tests/test_render_entities.py @@ -724,7 +724,7 @@ def test_entities_icon_style_none_no_circle(self) -> None: ) def test_entities_2level_always_outlined(self) -> None: - # On a 2-level display (grayscale_levels=2) the auto-switch + # On a 2-level display (display_levels=2) the auto-switch # forces outlined even for an active entity (state "on"); # top ring has no gray fill. m = _compute_metrics(80) @@ -738,7 +738,7 @@ def test_entities_2level_always_outlined(self) -> None: "entities": ["binary_sensor.motion"], } ] - img = render_to_image(widgets, self._config(grayscale_levels=2)) + img = render_to_image(widgets, self._config(display_levels=2)) rx1, ry1, rx2, ry2 = _icon_ring_region( 80, m, stroke_inset=m.border * 3 // 2 ) @@ -767,7 +767,7 @@ def test_entities_2level_divider_stroke_widened(self) -> None: "sensor.humidity", ], } - svg = render_widget_svg(w, self._config(grayscale_levels=2)) + svg = render_widget_svg(w, self._config(display_levels=2)) expected_sw = m.divider * 3 assert f'stroke-width="{expected_sw}"' in svg, ( f"2-level divider stroke-width should be {expected_sw}" diff --git a/tests/test_render_entity.py b/tests/test_render_entity.py index e99ce05..2562332 100644 --- a/tests/test_render_entity.py +++ b/tests/test_render_entity.py @@ -179,10 +179,10 @@ def test_entity_card_style_none_is_default(self) -> None: # enough icon circle to measure the ring region reliably. def _icon_ring( - self, h: int, card_style: str = "none", grayscale_levels: int = 16 + self, h: int, card_style: str = "none", display_levels: int = 16 ) -> tuple[int, int, int, int, int, int]: """Delegate to module-level _right_icon_ring_region.""" - return _right_icon_ring_region(400, h, card_style, grayscale_levels) + return _right_icon_ring_region(400, h, card_style, display_levels) def test_entity_icon_circle_gray_fill_active(self) -> None: # An active entity (state "on") without explicit icon_style @@ -320,10 +320,10 @@ def test_entity_icon_style_none_no_circle(self) -> None: ) def test_entity_2level_always_outlined(self) -> None: - # On a 2-level display (grayscale_levels=2), the auto-switch + # On a 2-level display (display_levels=2), the auto-switch # forces "outlined" even for an active entity (state "on"). h = 224 - _, _, rx1, ry1, rx2, ry2 = self._icon_ring(h, grayscale_levels=2) + _, _, rx1, ry1, rx2, ry2 = self._icon_ring(h, display_levels=2) widgets = [ { "type": "entity", @@ -334,7 +334,7 @@ def test_entity_2level_always_outlined(self) -> None: "entity": "binary_sensor.motion", } ] - img = render_to_image(widgets, self._config(grayscale_levels=2)) + img = render_to_image(widgets, self._config(display_levels=2)) found_gray = False for y in range(ry1, ry2): for x in range(rx1, rx2): diff --git a/tests/test_render_graph.py b/tests/test_render_graph.py index f636b6e..813ebdc 100644 --- a/tests/test_render_graph.py +++ b/tests/test_render_graph.py @@ -369,12 +369,12 @@ def test_graph_missing_entity_renders(self) -> None: assert " None: - # On 2-level displays (grayscale_levels=2), the fill element + # On 2-level displays (display_levels=2), the fill element # is still included in the SVG; dithering happens at the # optimize stage, not the render stage. svg = render_widget_svg( self._base_widget(smoothing=False), - self._config(grayscale_levels=2), + self._config(display_levels=2), ) assert " None: # contains elements for horizontal grid lines. svg = render_widget_svg( self._base_widget(show_labels=True), - self._config(grayscale_levels=16), + self._config(display_levels=16), ) assert " None: - # On 2-level displays (grayscale_levels=2), grid lines are + # On 2-level displays (display_levels=2), grid lines are # suppressed (fine gray lines cannot be rendered on B&W). svg = render_widget_svg( self._base_widget(show_labels=True), - self._config(grayscale_levels=2), + self._config(display_levels=2), ) assert " None: expected_color = color_to_hex(COLOR_LIGHT_GRAY) svg = render_widget_svg( self._base_widget(show_labels=True), - self._config(grayscale_levels=16), + self._config(display_levels=16), ) assert expected_color in svg @@ -1107,7 +1107,7 @@ def test_graph_legend_has_line_sample_elements(self) -> None: # samples (at least one per legend entry). svg = render_widget_svg( self._multi_widget(smoothing=False), - self._config(grayscale_levels=2), + self._config(display_levels=2), ) # On 2-level display, grid lines are absent so any # must come from legend samples. @@ -1491,7 +1491,7 @@ def test_graph_bar_legend_uses_rect_swatches(self) -> None: # samples. With bar mode, no elements should appear. svg = render_widget_svg( self._bar_multi_widget(), - self._config(grayscale_levels=2), + self._config(display_levels=2), ) # Legend entity names appear. assert "Living Room" in svg @@ -1708,7 +1708,7 @@ def test_graph_thresholds_suppressed_on_2level(self) -> None: # entirely: no appears even when configured. svg = render_widget_svg( self._threshold_widget(), - self._threshold_config(grayscale_levels=2), + self._threshold_config(display_levels=2), ) assert " None: black_hex = color_to_hex(COLOR_BLACK) svg = render_widget_svg( self._threshold_widget(), - self._threshold_config(grayscale_levels=2), + self._threshold_config(display_levels=2), ) # Gradient URL must not appear for stroke; black must be present. assert "thresh-stroke" not in svg @@ -1741,7 +1741,7 @@ def test_graph_thresholds_bar_suppressed_on_2level(self) -> None: black_hex = color_to_hex(COLOR_BLACK) svg = render_widget_svg( self._threshold_widget(graph="bar"), - self._threshold_config(grayscale_levels=2), + self._threshold_config(display_levels=2), ) # All bar fills should be black (entity-level), not per-bar. assert black_hex in svg @@ -2391,7 +2391,7 @@ def test_threshold_gradient_stops_out_of_range(self) -> None: {"value": 200, "shade": "dark"}, ] stops = _threshold_gradient_stops( - thresholds, "smooth", y_min=0.0, y_max=100.0, grayscale_levels=16 + thresholds, "smooth", y_min=0.0, y_max=100.0, display_levels=16 ) # Both stops must still be present; offsets are clamped. assert len(stops) == 2 diff --git a/tests/test_render_sensor_card.py b/tests/test_render_sensor_card.py index 092c3f5..7a2190b 100644 --- a/tests/test_render_sensor_card.py +++ b/tests/test_render_sensor_card.py @@ -116,10 +116,10 @@ def _icon_ring( self, h: int, card_style: str = "none", - grayscale_levels: int = 16, + display_levels: int = 16, ) -> tuple[int, int, int, int, int, int]: """Delegate to module-level _right_icon_ring_region.""" - return _right_icon_ring_region(400, h, card_style, grayscale_levels) + return _right_icon_ring_region(400, h, card_style, display_levels) # ── Structural tests ────────────────────────────── @@ -346,10 +346,10 @@ def test_sensor_icon_style_none_no_circle(self) -> None: ) def test_sensor_2level_always_outlined(self) -> None: - # On 2-level displays (grayscale_levels=2), active entity is + # On 2-level displays (display_levels=2), active entity is # forced to outlined style regardless of state. h = 224 - _, _, rx1, ry1, rx2, ry2 = self._icon_ring(h, grayscale_levels=2) + _, _, rx1, ry1, rx2, ry2 = self._icon_ring(h, display_levels=2) widgets = [ { "type": "sensor", @@ -360,7 +360,7 @@ def test_sensor_2level_always_outlined(self) -> None: "entity": "binary_sensor.motion", } ] - img = render_to_image(widgets, self._config(grayscale_levels=2)) + img = render_to_image(widgets, self._config(display_levels=2)) found_gray = False for y in range(ry1, ry2): for x in range(rx1, rx2): @@ -718,7 +718,7 @@ def test_sensor_graph_single_point_no_crash(self) -> None: assert " None: - # At grayscale_levels=16 (8-bit), a light-gray filled area + # At display_levels=16 (8-bit), a light-gray filled area # appears below the polyline. Checked via fill color rather # than a specific SVG element name ( or ). widget = { @@ -734,14 +734,14 @@ def test_sensor_graph_8bit_fill(self) -> None: widget, self._config( states=MOCK_SENSOR_WITH_HISTORY, - grayscale_levels=16, + display_levels=16, ), ) fill_hex = color_to_hex(COLOR_LIGHT_GRAY) assert fill_hex in svg def test_sensor_graph_2level_fill_present(self) -> None: - # At grayscale_levels=2 the fill polygon is rendered; Floyd-Steinberg + # At display_levels=2 the fill polygon is rendered; Floyd-Steinberg # dithering in the optimize pipeline converts it to a dot pattern. widget = { "type": "sensor", @@ -756,7 +756,7 @@ def test_sensor_graph_2level_fill_present(self) -> None: widget, self._config( states=MOCK_SENSOR_WITH_HISTORY, - grayscale_levels=2, + display_levels=2, ), ) fill_hex = color_to_hex(COLOR_LIGHT_GRAY) @@ -784,7 +784,7 @@ def test_sensor_graph_2level_stroke_widened(self) -> None: widget, self._config( states=MOCK_SENSOR_WITH_HISTORY, - grayscale_levels=2, + display_levels=2, ), ) expected_sw = m.border * 2 diff --git a/tests/test_render_separator.py b/tests/test_render_separator.py index 5200d1f..d2f5a27 100644 --- a/tests/test_render_separator.py +++ b/tests/test_render_separator.py @@ -108,8 +108,8 @@ def test_separator_vertical_explicit_length(self) -> None: assert pixel(img, 50, PADDING + 82) == 255 def test_separator_bar_2level_widens(self) -> None: - # grayscale_levels=2 widens a bar to ~10-12px. - config = {**self._CONFIG, "grayscale_levels": 2} + # display_levels=2 widens a bar to ~10-12px. + config = {**self._CONFIG, "display_levels": 2} widgets = [ {"type": "separator", "x": PADDING, "y": 50, "style": "bar"} ] @@ -121,7 +121,7 @@ def test_separator_bar_2level_widens(self) -> None: def test_separator_line_ignores_2level(self) -> None: # style="line" stays 2px even on 2-level displays. - config = {**self._CONFIG, "grayscale_levels": 2} + config = {**self._CONFIG, "display_levels": 2} widgets = [{"type": "separator", "x": PADDING, "y": 50}] img = render_to_image(widgets, config) assert pixel(img, 100, 52) == 255 diff --git a/tests/test_render_tile.py b/tests/test_render_tile.py index 7c6e3de..7568fe6 100644 --- a/tests/test_render_tile.py +++ b/tests/test_render_tile.py @@ -350,7 +350,7 @@ def test_tile_icon_style_none_no_circle(self) -> None: ) def test_tile_2level_always_outlined(self) -> None: - # On a 2-level display (grayscale_levels=2) the auto-switch + # On a 2-level display (display_levels=2) the auto-switch # forces "outlined" even for an active entity (state "on"), # so the top ring above the icon glyph has no gray fill. m = _compute_metrics(80) @@ -364,7 +364,7 @@ def test_tile_2level_always_outlined(self) -> None: "entity": "binary_sensor.motion", } ] - img = render_to_image(widgets, self._config(grayscale_levels=2)) + img = render_to_image(widgets, self._config(display_levels=2)) cy = 80 // 2 r = m.icon_dia // 2 cx = m.padding + r diff --git a/tests/test_render_utils.py b/tests/test_render_utils.py index f6115b2..c879092 100644 --- a/tests/test_render_utils.py +++ b/tests/test_render_utils.py @@ -562,7 +562,7 @@ def test_default_state_val_treated_as_inactive(self) -> None: # Omitting state_val uses the default "" which is not an # active state, so auto-resolves to outlined on multi-level. icon_outline, icon_no_circle = _resolve_icon_style( - None, grayscale_levels=16 + None, display_levels=16 ) assert icon_outline assert not icon_no_circle diff --git a/tests/test_render_waste_schedule.py b/tests/test_render_waste_schedule.py index 6a8a3af..17c433b 100644 --- a/tests/test_render_waste_schedule.py +++ b/tests/test_render_waste_schedule.py @@ -250,7 +250,7 @@ def test_waste_schedule_2level_icon_stroke_widened(self) -> None: w = self._widget(entries=entries, h=DEFAULT_ROW_H) with patch(_PATCH_NOW, wraps=dt.date) as mock_dt: mock_dt.today.return_value = _TODAY - svg = render_widget_svg(w, self._config(grayscale_levels=2)) + svg = render_widget_svg(w, self._config(display_levels=2)) expected_sw = m.border * 3 assert f'stroke-width="{expected_sw}"' in svg, ( f"2-level icon stroke-width should be {expected_sw}" @@ -264,7 +264,7 @@ def test_waste_schedule_2level_divider_stroke_widened(self) -> None: w = self._widget(h=3 * DEFAULT_ROW_H) with patch(_PATCH_NOW, wraps=dt.date) as mock_dt: mock_dt.today.return_value = _TODAY - svg = render_widget_svg(w, self._config(grayscale_levels=2)) + svg = render_widget_svg(w, self._config(display_levels=2)) expected_sw = m.divider * 3 assert f'stroke-width="{expected_sw}"' in svg, ( f"2-level divider stroke-width should be {expected_sw}" diff --git a/tests/test_websocket.py b/tests/test_websocket.py index 22b1b29..8052140 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -249,10 +249,10 @@ async def test_config_includes_entity_states( assert "sensor.temp" in config["states"] assert config["states"]["sensor.temp"]["state"] == "21.5" - async def test_config_includes_grayscale_levels( + async def test_config_includes_display_levels( self, hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: - # grayscale_levels comes from entry.options, not the device preset. + # display_levels comes from entry.options, not the device preset. widget = {"type": "separator"} client, entry = await _setup_entry( hass, @@ -262,7 +262,7 @@ async def test_config_includes_grayscale_levels( "device_model": "kindle_pw", "width": 758, "height": 1024, - "grayscale_levels": 4, + "display_levels": 4, }, ) @@ -270,7 +270,7 @@ async def test_config_includes_grayscale_levels( await _send_render_widget(client, entry.entry_id, 0) _, config = mock_render.call_args.args - assert config["grayscale_levels"] == 4 + assert config["display_levels"] == 4 async def test_config_includes_battery_level( self, hass: HomeAssistant, hass_ws_client: WebSocketGenerator @@ -538,7 +538,7 @@ async def test_config_built_once( return_value={ "width": 758, "height": 1024, - "grayscale_levels": 2, + "display_levels": 2, "states": {}, }, ) as mock_cfg,