uv run --group test pytest # run all tests
uv run --group test pytest tests/test_render.py::TestRenderWeather::test_weather_draws_temperature # run a single test
uv run --group lint ruff check . # lint
uv run --group format ruff format --check . # format check
uv run --group typecheck ty check # typecheck
uv run --group interrogate interrogate -vv custom_components/eink_dashboard/ # docstring coverageAfter making changes, ALWAYS run:
uv run --group format ruff format . && uv run --group lint ruff check . && uv run --group format ruff format --check . && uv run --group typecheck ty check && uv run --group test pytestfor Python changespnpm --dir custom_components/eink_dashboard/frontend typecheck && pnpm --dir custom_components/eink_dashboard/frontend testfor TypeScript changes
This is a Home Assistant custom component (custom_components/eink_dashboard/)
that renders e-ink dashboard images as PNG bytes and exposes them via an HA
image entity and a public HTTP endpoint.
Key files:
render.py— rendering orchestrator and shared helpers (delegates tosvg_render.py; also holdsWidgetMetrics,_compute_metrics,color_to_hex,DEFAULT_METRICS,_load_font, and other utilities imported lazily by widget modules andwidgets/_helpers.py)svg_render.py— SVG rendering pipeline: Jinja2 templates, icon-inlining filters (_mdi_svg_filter,_weather_svg_filter),render_widget_svg(),_compose_svg(),_svg_to_png()viaresvg_pywidgets/— per-widget SVG context builders (_build_*_context()), one module per widget type; re-exported viawidgets/__init__.pywidgets/_helpers.py— shared layout helpers for widget builders:_color_context(),_widget_dim(),_card_insets(),_metrics_context(),_title_layout(),_auto_row_height(),_fmt(),_entity_info_context(),_ACTIVE_STATESimage.py—EinkDashboardImage(ImageEntity), scheduled refresh, ETag trackinghttp.py— unauthenticated HTTP view at/api/eink_dashboard/{entry_id}/image.pngwith ETag/304 supportstore.py—EinkDashboardStore, persists widget list via HA'sStore(eink_dashboard.{entry_id})config_flow.py— multi-step config flow: name/dimensions/interval, plus TRMNL webhook management (add/remove named webhook targets)optimize.py—optimize_for_eink(img, config): optional post-render pipeline (autocontrast, sharpness, contrast, grayscale level quantization)push.py—async_push_image(session, url, image_bytes): HTTP POST of PNG bytes to a webhook URLsensor.py—EinkDashboardSensor, exposes dashboard state as an HA sensor entityconst.py— enums, shared constants, and defaults
MDI icons: MDI icons are resolved at runtime by _mdi_svg_filter()
in svg_render.py via a two-stage lookup:
hass_frontend(production) — thehass-frontendpip package shipsstatic/mdi/iconMetadata.json(a list of chunk descriptors) and per-chunk JSON files mapping icon names to SVGdpath strings._load_hass_mdi_metadata()reads the metadata and_resolve_mdi_path()usesbisectto find the right chunk for a given name.- npm
@mdi/svg(development / testing fallback) — individual SVG files infrontend/node_modules/@mdi/svg/svg/(pnpm top-level symlink). Used automatically whenhass_frontendis not installed. No curated icon subset is needed; all 7 400+ MDI icons are available through these sources without copying files manually.
Rendering entry point: render_dashboard(widget_list, config) -> bytes in render.py
configis aDisplayConfigdict withwidth,height,rotation, andstates(HA entity ID → state dict)- Default display: 758×1024 px, 8-bit grayscale (
"L"mode) - Dispatches each widget to its SVG context builder via
_SVG_RENDERERSinsvg_render.py, composes one root SVG, rasterises withresvg_py, then applies rotation and e-ink optimisation, and returns PNG bytes
Widget types (WidgetType in const.py): ENTITY, HEADING,
SEPARATOR, TILE, WEATHER, DEVICE_BATTERY, WASTE_SCHEDULE.
Deprecated types (hidden from the widget picker but kept for existing
configs): TEXT (superseded by HEADING), SENSOR_ROWS,
STATUS_ICONS.
Adding a widget type:
- Add the new value to
WidgetTypeinconst.py - Create
templates/foo.svg.j2(may import_macros.svg.j2helpers) - Write
_build_foo_context(widget, config) -> dictinwidgets/foo.py - Re-export from
widgets/__init__.pyand register in_SVG_RENDERERSinsvg_render.py - Add the widget type to
frontend/src/types/ha.d.ts - Add the schema and entry to
WIDGET_TYPESinfrontend/src/eink-dashboard-editor.ts
Converting or redesigning a widget type (PIL→SVG migration or new
widget):
Do not write code directly. Invoke the three skills in order using the
Skill tool — each one must complete before the next is called:
/implement-widget-tests— write failing tests (TDD red phase)/implement-widget— implement the SVG template and Python context builder (green phase)/implement-widget-frontend— TS types and editor schema
Line length: 79 characters. Long comments must be wrapped across multiple lines — never shortened to fit. Split at word boundaries so each line stays under the limit. Do not abbreviate words or remove meaning to fit on one line.
Documentation conventions:
- All functions get a docstring. Small, trivial helpers may use a single
summary line. Anything with non-obvious behaviour, multiple parameters, or
a return value gets a full docstring: summary line, description paragraph,
and all parameters and return values documented. Python uses Google-style
(
Args:,Returns:); TypeScript uses JSDoc (@param,@returns). - All TypeScript interfaces, classes, and their members must be documented
with JSDoc
/** … */block comments. Interface-level comment: one-line summary of what the type represents. Member-level comment: explain the purpose and valid values, not just the type. Deprecated members get a@deprecatednote explaining what to use instead. - Python dataclasses, enums, and their fields get docstrings or inline comments with the same level of detail.
- Add an inline comment when the WHY is non-obvious: a hidden constraint, a loop's non-obvious exit condition, a state-machine transition, a workaround for a specific quirk, or geometry/centering math where the formula is not self-evident from the variable names.
- Section headers that name a logical group of steps are encouraged, even when they describe what the group does — they help readers scan the function structure. Avoid per-line narration that merely restates what an individual statement does.
- Each test function must have a short comment at the top of the function body explaining what the test is verifying.
Colors: Integers 0–255. Constants in const.py: COLOR_BLACK=0,
COLOR_WHITE=255, COLOR_GRAY=120, COLOR_LIGHT_GRAY=180, PADDING=24,
DEFAULT_ROW_H=56, DEFAULT_CARD_STYLE="none".
FONT_SIZE_WEATHER=32 (scale denominator for weather geometry; all
other per-widget font-size constants removed in Step 1.7). Use
color_to_hex(c) in render.py to
convert an integer constant to an SVG hex string (e.g. COLOR_GRAY →
"#787878"); spread _color_context() from svg_render.py into context
dicts so templates receive hex_black, hex_white, hex_gray variables
instead of hardcoded literals. DEFAULT_METRICS = _compute_metrics( DEFAULT_ROW_H) in render.py is the frozen WidgetMetrics instance for
the standard row height; use its fields (icon_dia, icon_inner,
font_primary, etc.) rather than deriving sizes inline.
Fonts: _load_font(size, medium=False, bold=False) (LRU-cached)
loads fonts/Roboto/Roboto-Regular.ttf (or Roboto-Medium.ttf when
medium=True, or Roboto-Bold.ttf when bold=True; bold takes
precedence over medium), falling back to PIL's built-in default.
Localization: Weekday/month abbreviations (_weekday_abbrev,
_month_abbrev in render.py) use babel.dates.format_date against
CLDR data, falling back from the full locale to its primary subtag
to English on UnknownLocaleError/ValueError. Relative date
phrases ("today"/"tomorrow"/"in N days") use a curated
_RELATIVE_DAY_PHRASES dict in render.py (via
_relative_day_phrases()/_format_relative_date()), limited to
languages with grammatically simple, invariant substitution; other
languages fall back to English. Requires the babel package
(listed in both pyproject.toml and manifest.json's
requirements — the latter is what a real HA install reads at
runtime, so both must be kept in sync when adding a dependency).
Rendering: Widget SVGs are rendered server-side by svg_render.py
and fetched by the Lovelace card via the eink_dashboard/render_widgets
WebSocket command. There is no client-side canvas renderer. When
changing layout, data fields, or coordinates, only svg_render.py and
the Jinja2 templates need updating.
Tests assert visual correctness by scanning pixel regions for dark/gray
pixels. Helpers in tests/helpers.py: pixel(img, x, y) reads a grayscale
pixel value, content_bbox(img) finds the tight bounding box of non-white
pixels, assert_has_dark_pixels() / assert_all_white() /
assert_has_gray_pixels() check regions, assert_vertically_centered()
and assert_scales_proportionally() verify layout geometry,
make_config() builds config dicts, png_to_image() converts PNG bytes
to a PIL Image. Weather forecast entries must include a "datetime" ISO
8601 field so day labels can be derived without timezone assumptions.
Test infrastructure: Tests run without a real Home Assistant installation.
tests/conftest.py injects stub modules into sys.modules before any import.
All HA selectors (SelectSelector, AreaSelector, TextSelector,
EntitySelector, LanguageSelector and their config/enum companions) are
stubbed as pass-through identity functions —
__call__ returns its argument unchanged. Other key stubs: config/options flow
base classes (return plain dicts), ImageEntity, HomeAssistantView,
HomeAssistant (MagicMock), and cv.url (real urlparse-based validation).
When adding new HA imports to production code, add matching stubs in
tests/conftest.py.
Commit and PR hygiene:
- One concern per PR. A config-flow UX change and a new widget type are two PRs, not one. If a PR touches unrelated areas it is too big to review.
- No fix-then-fix-again chains. Do not commit code that you then correct or rewrite in a later commit in the same PR. Validate the approach (run tests, check the dependency is right, verify the rendering) before committing. If a mistake slips through, amend or squash — the reviewer should never see a commit that only exists to fix a previous commit in the same PR.
- Squash iterative polish. Moving a label, tweaking wording, and adjusting alignment on the same feature are one commit, not three. Interactive-rebase before opening the PR.
- Each commit must stand on its own. Tests pass, lint passes, no half-finished code. A reviewer should be able to check out any single commit and get a working tree.
Home Assistant Core Sources: ./.tmp/core/
Home Assistant Frontend Sources: ./.tmp/frontend/