Skip to content

Commit 7529c7f

Browse files
cryptomilkclaude
andcommitted
feat(config-flow): isolate oversized displays in generated dashboard YAML
The "Copy dashboard YAML" export listed every configured display as a flat list of cards, so a landscape reTerminal E1003 (1872x1404) ended up squeezed into the same row as much smaller Kindle cards. Switch the generator to a Lovelace `sections` layout: entries whose width or height is more than 1.5x the median (or the smaller of two, when there are too few entries for a median) get their own full-width section, while similarly sized entries continue to share one row. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 907fb08 commit 7529c7f

3 files changed

Lines changed: 314 additions & 7 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,9 @@ After setup, click **Configure** on the integration entry to:
109109
- **Add / remove push target** - manage TRMNL webhook URLs
110110
- **Copy card YAML** - get the Lovelace card snippet for this device
111111
- **Copy dashboard YAML** - get a full dashboard YAML with cards for all
112-
configured devices
112+
configured devices, using a `sections` layout that gives any
113+
disproportionately large display (e.g. a landscape reTerminal E1003) its
114+
own row instead of squeezing it in next to smaller cards
113115

114116
## Dashboard setup
115117

custom_components/eink_dashboard/config_flow.py

Lines changed: 133 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from __future__ import annotations
1818

1919
import os
20+
import statistics
2021
from copy import deepcopy
2122
from typing import TYPE_CHECKING, Any
2223
from urllib.parse import urlparse
@@ -318,6 +319,137 @@ def _screen_portion_options(
318319
]
319320

320321

322+
# An entry's width or height exceeding this ratio versus the baseline
323+
# marks it as "large" for _split_large_entries. Chosen so a landscape
324+
# reTerminal E1003 (1872x1404) next to a portrait Kindle Paperwhite
325+
# (758x1024) is isolated, while a Kindle Paperwhite 4 (1072x1448)
326+
# stays grouped with a Kindle Paperwhite.
327+
_LARGE_SIZE_RATIO = 1.5
328+
329+
330+
def _entry_dimensions(entry: ConfigEntry) -> tuple[int, int]:
331+
"""Return an entry's stored card dimensions.
332+
333+
Args:
334+
entry: A config entry for the ``eink_dashboard`` domain.
335+
336+
Returns:
337+
A (width, height) tuple, falling back to the default canvas
338+
size if the entry has not stored dimensions yet. Each value
339+
is clamped to a minimum of 1 to guard against hand-edited
340+
storage containing zero, which would otherwise cause a
341+
division by zero in ``_split_large_entries``.
342+
"""
343+
return (
344+
max(entry.options.get("width", DEFAULT_WIDTH), 1),
345+
max(entry.options.get("height", DEFAULT_HEIGHT), 1),
346+
)
347+
348+
349+
def _split_large_entries(
350+
entries: list[ConfigEntry],
351+
) -> tuple[list[ConfigEntry], list[ConfigEntry]]:
352+
"""Split entries into normal-sized and oversized groups.
353+
354+
An entry is "large" when its width or height exceeds
355+
``_LARGE_SIZE_RATIO`` times the baseline for that dimension, so
356+
generated dashboard YAML can give it its own full-width section
357+
instead of squeezing it into a shared row. The baseline is the
358+
median width/height across all entries, or the minimum when
359+
there are fewer than three entries (a median is meaningless
360+
with so few samples and would flag the larger of just two
361+
entries as an outlier).
362+
363+
Args:
364+
entries: Config entries for the ``eink_dashboard`` domain.
365+
366+
Returns:
367+
A tuple of (normal-sized entries, large entries), each
368+
preserving the input order.
369+
"""
370+
dims = [_entry_dimensions(e) for e in entries]
371+
if len(entries) >= 3:
372+
baseline_w = statistics.median(w for w, _ in dims)
373+
baseline_h = statistics.median(h for _, h in dims)
374+
else:
375+
baseline_w = min((w for w, _ in dims), default=DEFAULT_WIDTH)
376+
baseline_h = min((h for _, h in dims), default=DEFAULT_HEIGHT)
377+
378+
normal: list[ConfigEntry] = []
379+
large: list[ConfigEntry] = []
380+
for entry, (width, height) in zip(entries, dims, strict=True):
381+
ratio = max(width / baseline_w, height / baseline_h)
382+
(large if ratio > _LARGE_SIZE_RATIO else normal).append(entry)
383+
return normal, large
384+
385+
386+
def _grid_section(
387+
entries: list[ConfigEntry],
388+
full_width: bool = False,
389+
) -> str:
390+
"""Build one ``type: grid`` section block for the dashboard YAML.
391+
392+
Args:
393+
entries: Config entries whose cards appear in this section.
394+
full_width: When ``True``, each card gets
395+
``grid_options: columns: full`` so it spans the entire
396+
view width instead of sharing the row.
397+
398+
Returns:
399+
A YAML string fragment for a single sections-view grid.
400+
"""
401+
# entry_id is always an HA-generated hex/ULID string, so it
402+
# never needs YAML escaping here.
403+
suffix = (
404+
"\n grid_options:\n columns: full"
405+
if full_width
406+
else ""
407+
)
408+
cards = "\n".join(
409+
" - type: custom:eink-dashboard-card\n"
410+
f" config_entry: {e.entry_id}{suffix}"
411+
for e in entries
412+
)
413+
return (
414+
f" - type: grid\n cards:\n{cards}\n column_span: 10"
415+
)
416+
417+
418+
def _build_dashboard_yaml(entries: list[ConfigEntry]) -> str:
419+
"""Build a Lovelace ``sections`` view YAML for all given entries.
420+
421+
Normal-sized entries share one grid section so they lay out side
422+
by side; entries much larger than the rest (see
423+
``_split_large_entries``) each get their own full-width section.
424+
425+
Args:
426+
entries: Config entries for the ``eink_dashboard`` domain.
427+
The caller (``async_step_copy_dashboard_yaml``) always
428+
passes at least one entry, since the options flow's own
429+
config entry is always part of the domain's entries.
430+
431+
Returns:
432+
A YAML string for a full Lovelace dashboard view.
433+
"""
434+
normal, large = _split_large_entries(entries)
435+
436+
sections = []
437+
if normal:
438+
sections.append(_grid_section(normal))
439+
sections.extend(_grid_section([e], full_width=True) for e in large)
440+
441+
sections_yaml = "\n".join(sections)
442+
return (
443+
"views:\n"
444+
" - type: sections\n"
445+
" max_columns: 10\n"
446+
" sections:\n"
447+
f"{sections_yaml}\n"
448+
" title: E-Ink Dashboards\n"
449+
" cards: []"
450+
)
451+
452+
321453
class EinkDashboardConfigFlow(ConfigFlow, domain=DOMAIN):
322454
"""Multi-step config flow for creating a new dashboard entry."""
323455

@@ -671,12 +803,7 @@ async def async_step_copy_dashboard_yaml(
671803
if user_input is not None:
672804
return await self.async_step_init()
673805
entries = self.hass.config_entries.async_entries(DOMAIN)
674-
cards = "\n".join(
675-
f" - type: custom:eink-dashboard-card\n"
676-
f" config_entry: {e.entry_id}"
677-
for e in entries
678-
)
679-
yaml = f"views:\n - title: E-Ink Dashboards\n cards:\n{cards}"
806+
yaml = _build_dashboard_yaml(entries)
680807
return self.async_show_form(
681808
step_id="copy_dashboard_yaml",
682809
data_schema=vol.Schema({}),

tests/test_config_flow.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
import pytest
2121
import voluptuous as vol
22+
import yaml # via homeassistant -> pyyaml
2223
from homeassistant.config_entries import SOURCE_USER
2324
from homeassistant.data_entry_flow import FlowResultType
2425
from homeassistant.helpers import entity_registry as er
@@ -1897,6 +1898,73 @@ async def test_copy_dashboard_yaml_shows_all_entries(
18971898
assert "aaa111" in yaml
18981899
assert "bbb222" in yaml
18991900
assert "eink-dashboard-card" in yaml
1901+
assert "type: sections" in yaml
1902+
1903+
async def test_copy_dashboard_yaml_groups_similar_sizes_together(
1904+
self, hass: HomeAssistant
1905+
) -> None:
1906+
# Entries with similar card sizes share one grid section and
1907+
# get no explicit grid_options, since the default sizing
1908+
# already fits them side by side. _make_options_flow's own
1909+
# backing entry (default 758x1024) is also included in the
1910+
# entries list and contributes to the baseline calculation.
1911+
flow = await _make_options_flow(hass, {"webhook_urls": []})
1912+
entry_a = MockConfigEntry(
1913+
domain=DOMAIN,
1914+
entry_id="kindle_pw",
1915+
options={"width": 758, "height": 1024},
1916+
)
1917+
entry_a.add_to_hass(hass)
1918+
entry_b = MockConfigEntry(
1919+
domain=DOMAIN,
1920+
entry_id="kindle_pw4",
1921+
options={"width": 1072, "height": 1448},
1922+
)
1923+
entry_b.add_to_hass(hass)
1924+
result = await flow.async_step_copy_dashboard_yaml(None)
1925+
1926+
yaml = result["description_placeholders"]["yaml"]
1927+
assert yaml.count("type: grid") == 1
1928+
assert "grid_options" not in yaml
1929+
1930+
async def test_copy_dashboard_yaml_isolates_oversized_entry(
1931+
self, hass: HomeAssistant
1932+
) -> None:
1933+
# An entry much larger than the others (e.g. a landscape
1934+
# reTerminal E1003 next to portrait Kindles) gets its own
1935+
# full-width grid section instead of sharing a row.
1936+
# _make_options_flow's own backing entry (default 758x1024)
1937+
# is also included in the entries list and contributes to
1938+
# the median baseline, making this a 4-entry median.
1939+
flow = await _make_options_flow(hass, {"webhook_urls": []})
1940+
entry_a = MockConfigEntry(
1941+
domain=DOMAIN,
1942+
entry_id="kindle_pw",
1943+
options={"width": 758, "height": 1024},
1944+
)
1945+
entry_a.add_to_hass(hass)
1946+
entry_b = MockConfigEntry(
1947+
domain=DOMAIN,
1948+
entry_id="kindle_oasis",
1949+
options={"width": 1264, "height": 1680},
1950+
)
1951+
entry_b.add_to_hass(hass)
1952+
entry_huge = MockConfigEntry(
1953+
domain=DOMAIN,
1954+
entry_id="reterminal_e1003",
1955+
options={"width": 1872, "height": 1404},
1956+
)
1957+
entry_huge.add_to_hass(hass)
1958+
result = await flow.async_step_copy_dashboard_yaml(None)
1959+
1960+
yaml = result["description_placeholders"]["yaml"]
1961+
assert yaml.count("type: grid") == 2
1962+
assert yaml.count("grid_options") == 1
1963+
assert yaml.count("columns: full") == 1
1964+
sections = yaml.split("type: grid")[1:]
1965+
huge_section = next(s for s in sections if "reterminal_e1003" in s)
1966+
assert "kindle_pw" not in huge_section
1967+
assert "kindle_oasis" not in huge_section
19001968

19011969
async def test_copy_dashboard_yaml_submit_returns_to_init(
19021970
self, hass: HomeAssistant
@@ -1908,6 +1976,116 @@ async def test_copy_dashboard_yaml_submit_returns_to_init(
19081976
assert result["type"] is FlowResultType.MENU
19091977
assert result["step_id"] == "init"
19101978

1979+
async def test_copy_dashboard_yaml_parses_to_valid_structure(
1980+
self, hass: HomeAssistant
1981+
) -> None:
1982+
# Generated YAML is syntactically valid and matches the
1983+
# expected Lovelace sections-view structure: a shared grid
1984+
# for normal-sized entries and a full-width grid per
1985+
# oversized entry, each holding the right card config.
1986+
# _make_options_flow's own backing entry (default
1987+
# 758x1024) is also included in the entries list.
1988+
flow = await _make_options_flow(hass, {"webhook_urls": []})
1989+
entry_a = MockConfigEntry(
1990+
domain=DOMAIN,
1991+
entry_id="kindle_pw",
1992+
options={"width": 758, "height": 1024},
1993+
)
1994+
entry_a.add_to_hass(hass)
1995+
entry_huge = MockConfigEntry(
1996+
domain=DOMAIN,
1997+
entry_id="reterminal_e1003",
1998+
options={"width": 1872, "height": 1404},
1999+
)
2000+
entry_huge.add_to_hass(hass)
2001+
result = await flow.async_step_copy_dashboard_yaml(None)
2002+
2003+
raw = result["description_placeholders"]["yaml"]
2004+
doc = yaml.safe_load(raw)
2005+
view = doc["views"][0]
2006+
assert view["type"] == "sections"
2007+
assert view["max_columns"] == 10
2008+
assert view["title"] == "E-Ink Dashboards"
2009+
assert "path" not in view
2010+
sections = view["sections"]
2011+
assert len(sections) == 2
2012+
2013+
normal_section = sections[0]
2014+
assert normal_section["type"] == "grid"
2015+
assert normal_section["column_span"] == 10
2016+
assert len(normal_section["cards"]) == 2
2017+
assert all(
2018+
c["type"] == "custom:eink-dashboard-card"
2019+
for c in normal_section["cards"]
2020+
)
2021+
2022+
large_section = sections[1]
2023+
assert large_section["type"] == "grid"
2024+
assert len(large_section["cards"]) == 1
2025+
large_card = large_section["cards"][0]
2026+
assert large_card["config_entry"] == "reterminal_e1003"
2027+
assert large_card["grid_options"] == {"columns": "full"}
2028+
2029+
async def test_copy_dashboard_yaml_isolates_multiple_oversized(
2030+
self, hass: HomeAssistant
2031+
) -> None:
2032+
# Two entries much larger than the normals each get their
2033+
# own full-width grid section rather than sharing one.
2034+
# _make_options_flow's own backing entry (default
2035+
# 758x1024) is also included in the entries list.
2036+
flow = await _make_options_flow(hass, {"webhook_urls": []})
2037+
for entry_id in ("normal_a", "normal_b"):
2038+
entry = MockConfigEntry(
2039+
domain=DOMAIN,
2040+
entry_id=entry_id,
2041+
options={"width": 758, "height": 1024},
2042+
)
2043+
entry.add_to_hass(hass)
2044+
for entry_id, width, height in (
2045+
("large_a", 1872, 1404),
2046+
("large_b", 2560, 1600),
2047+
):
2048+
entry = MockConfigEntry(
2049+
domain=DOMAIN,
2050+
entry_id=entry_id,
2051+
options={"width": width, "height": height},
2052+
)
2053+
entry.add_to_hass(hass)
2054+
result = await flow.async_step_copy_dashboard_yaml(None)
2055+
2056+
raw = result["description_placeholders"]["yaml"]
2057+
assert raw.count("type: grid") == 3
2058+
assert raw.count("grid_options") == 2
2059+
assert raw.count("columns: full") == 2
2060+
2061+
async def test_copy_dashboard_yaml_exact_threshold_is_normal(
2062+
self, hass: HomeAssistant
2063+
) -> None:
2064+
# An entry at exactly 1.5x the baseline stays in the normal
2065+
# group, since _split_large_entries uses a strict > compare.
2066+
# _make_options_flow's own backing entry (default
2067+
# 758x1024) is also included in the entries list.
2068+
flow = await _make_options_flow(hass, {"webhook_urls": []})
2069+
entry_anchor = MockConfigEntry(
2070+
domain=DOMAIN,
2071+
entry_id="anchor",
2072+
options={"width": 758, "height": 1024},
2073+
)
2074+
entry_anchor.add_to_hass(hass)
2075+
# Exactly 1.5x the 758x1024 baseline: 758*1.5=1137,
2076+
# 1024*1.5=1536.
2077+
entry_boundary = MockConfigEntry(
2078+
domain=DOMAIN,
2079+
entry_id="boundary",
2080+
options={"width": 1137, "height": 1536},
2081+
)
2082+
entry_boundary.add_to_hass(hass)
2083+
result = await flow.async_step_copy_dashboard_yaml(None)
2084+
2085+
raw = result["description_placeholders"]["yaml"]
2086+
assert raw.count("type: grid") == 1
2087+
assert "grid_options" not in raw
2088+
19112089
async def test_display_settings_persists_entry_options(
19122090
self, hass: HomeAssistant
19132091
) -> None:

0 commit comments

Comments
 (0)