diff --git a/.env.example b/.env.example index 9c49abf..0776276 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,13 @@ LOG_LEVEL=info # the app has no authentication, so every allowed origin can read your # projects and push to your displays. CORS_ORIGINS= + +# Hostnames the editor answers to (DNS rebinding protection), comma separated. +# IP addresses, localhost, dot-less names and *.local / *.lan / *.home.arpa +# work out of the box. Add your reverse-proxy domain here, e.g. +# epaper.example.com or *.example.com. Use * to disable the check. +ALLOWED_HOSTS= + +# Home Assistant service domains "Send to display" may call, comma separated. +# Defaults to open_epaper_link. +ALLOWED_SERVICE_DOMAINS= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ab70260 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,99 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + backend: + name: Backend (tests + lint) + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: backend/requirements-dev.txt + + - name: Install dependencies + run: pip install -r requirements-dev.txt + + - name: Lint + run: ruff check app + + - name: Test + run: python -m pytest -q app + + frontend: + name: Frontend (syntax) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + # There is no build step: the browser loads the modules directly. Parsing + # every file still catches syntax errors and broken imports that a + # type-free module graph would otherwise surface only at runtime. + - name: Check module syntax + run: | + rc=0 + for file in frontend/js/*.js; do + if ! node --check "$file"; then + echo "::error file=$file::Syntax error" + rc=1 + fi + done + exit $rc + + - name: Check imports resolve + run: | + rc=0 + for file in frontend/js/*.js; do + # Every relative import must point at a file that exists. + while read -r target; do + [ -z "$target" ] && continue + resolved="$(dirname "$file")/$target" + if [ ! -f "$resolved" ]; then + echo "::error file=$file::Unresolved import '$target'" + rc=1 + fi + done < <(grep -oE "from '\./[^']+'" "$file" | sed "s/from '//; s/'$//") + done + exit $rc + + docker: + name: Docker image builds + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + # Build only; nothing is pushed. This catches a broken Dockerfile, a + # missing COPY source or a dependency that fails to install. + - name: Build image + uses: docker/build-push-action@v6 + with: + context: . + file: backend/Dockerfile + push: false + load: false + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index d8ed522..e058cf6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,11 @@ data/ .env .DS_Store .vscode/ +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ + +# Fonts for the pixel-accurate preview. Downloaded by +# backend/scripts/fetch_assets.py (and at Docker build time) with size and +# SHA-1 verification, so they are not committed. +backend/app/assets/ diff --git a/README.md b/README.md index bb50a06..6d80e3b 100644 --- a/README.md +++ b/README.md @@ -88,10 +88,14 @@ variables in `.env`: | `LOG_LEVEL` | `info` | uvicorn log level | | `DATA_DIR` | `/data` | Where projects and settings are stored | | `CORS_ORIGINS` | – | Extra browser origins allowed to call the API, comma separated | +| `ALLOWED_HOSTS` | – | Extra hostnames the editor answers to, e.g. `epaper.example.com`, `*.example.com`; `*` disables the check | +| `ALLOWED_SERVICE_DOMAINS` | `open_epaper_link` | Home Assistant service domains *Send to display* may call | Create a token in Home Assistant under **Profile → Security → Long-lived access -tokens**. Values saved in the UI take precedence over environment variables, -except that the environment token is used when none is saved. +tokens**. Values saved in the UI take precedence over environment variables. +The token is bound to the URL it belongs to: `HA_TOKEN` is only sent to +`HA_URL`, and changing the URL in the UI discards the saved token, so enter the +token again together with a new URL. --- @@ -107,7 +111,8 @@ except that the environment token is used when none is saved. | Resize | Drag the corner / edge handles | | Duplicate | `Ctrl`/`Cmd` + `D` | | Delete | `Delete` / `Backspace` | -| Centre horizontally | `C` | +| Centre horizontally | `C` (without modifiers) | +| Save now | `Ctrl`/`Cmd` + `S` | | Deselect | `Esc` | Toggle **Grid** and **Snap** in the toolbar. **Preview** hides the selection @@ -116,8 +121,10 @@ chrome so you see the design as it will render. ### Display resolution Pick a preset (2.9", 2.13", 4.2", 7.5" …) or enter a custom width/height. The -canvas resizes immediately and coordinates are always in display pixels, so the -generated payload matches the target panel. +canvas resizes immediately and coordinates are in display pixels, so the +generated payload matches the target panel. Position fields also accept +percentages of the canvas (`50%`), as `drawcustom` does; together with an +`anchor` such as `mm` this centres an element. ### Element types @@ -131,13 +138,25 @@ All types from the `drawcustom` documentation are supported: | Utility | `debug_grid` | Every documented property is exposed in the Inspector, grouped into -**Content / Position / Style / Advanced**. +**Content / Position / Style / Advanced** — including the plot's +`ylegend` / `yaxis` / `xlegend` / `xaxis` options under **Axes & legends** +(tick a box to switch one on). + +Where the OpenEPaperLink image generator derives a default from other +properties, the editor follows it: `multiline` anchors every line at `lm` +(left-middle), a multi-line or wrapped `text` gets an explicit `anchor`, and a +rectangle with `corners` always carries its `radius` (HA would otherwise round +with 10 px). ### Home Assistant templates Any text or number field accepts Jinja. Type `{{ states('sensor.temp') }}` into -a value and it is emitted verbatim (unquoted) so Home Assistant evaluates it at -render time. Fields containing `{{` or `{%` are tagged with a **jinja** badge. +a value and Home Assistant evaluates it at render time: in number fields the +expression is emitted bare (`"x": {{ 15 + i*spacing }}`), in text fields it +stays inside the JSON string (`"value": "{{ states('sensor.temp') }} °C"`). A +bare expression typed into a number field, such as `15 + i*spacing`, is wrapped +in `{{ }}` for you. Fields containing `{{` or `{%` are tagged with a **jinja** +badge. ### Variables @@ -157,13 +176,22 @@ A **Repeat group** wraps its children in a `{% for %}` loop — this is how the 1. Add a **Repeat group** from the palette (under *Structure*). 2. Drag the elements that should repeat onto the canvas. -3. Select the group and set the **loop variable** (`i`), **iterations** (`8`) - and any **pre-loop statements** (e.g. `offsets = [offset_0, offset_1, ...]`). +3. Select the group and set the **loop variable** (`i`), **iterations** (`8`, + or a Jinja expression such as `forecast | length`) and any **pre-loop + statements** (e.g. `offsets = [offset_0, offset_1, ...]`). 4. Use `i` in child fields: `{{ 15 + i*spacing }}`. +Groups can be nested (drag a group onto another one in the **Layers** tab) to +build nested loops, e.g. rows × columns; give each level its own loop variable. +An **Enabled** checkbox turned off makes a group a plain folder that emits its +children once. + The generator handles comma placement so the output is always valid JSON: a group that is the first element emits `{% if not loop.first %},{% endif %}` -instead of a leading comma. +instead of a leading comma, and a dynamic iteration count or nested groups +switch to a runtime flag (`oepl_ns`). Anything the generator has to skip, or a +nested group reusing its parent's loop variable, is listed as a warning above +the output in the **Code** tab. ### Exporting @@ -172,21 +200,93 @@ The **Code** tab shows the live output as **Jinja**, **YAML** or **JSON**. puts it on the clipboard for pasting into a script, automation or template sensor. +**✓ Validate** renders the template and checks the result, showing a green or +red badge: + +* **green** — the template rendered and produced a valid array of elements, with + the element count and where the render happened; +* **red** — with the stage that failed (*render*, *parse* or *elements*), the + error message, an excerpt with a caret under the offending character, and a + per-element list of problems such as `Element 2 (type 'line'): missing + required key 'x_end'`. + +Validation uses Home Assistant when it is reachable, so the check reflects live +entity state. Otherwise it falls back to a local Jinja sandbox with permissive +stubs: unresolved values render as `0`, which is enough to prove the template +renders and that the payload is well formed. This also catches the one thing +that cannot be checked statically — a quote that only appears at render time +inside a `{{ }}` expression, which produces invalid JSON. + +### Pixel-accurate preview + +**🖼 Pixel preview** renders the project with the *real* drawing code and shows +the result as a PNG — the same Pillow operations, fonts, colours, coordinate +rules and defaults the integration uses when it draws to a tag. + +This is the honest counterpart to the canvas. The canvas is a fast +approximation for editing; this is what the display will actually look like: + +* real `ppb.ttf` / `rbm.ttf` metrics, so text lands at the right width and wraps + where Home Assistant wraps it; +* real Material Design Icons glyphs, resolved through the shipped MDI metadata; +* the documented colour rules — `accent` follows the tag (switchable between red + and yellow in the dialog), `half_*` halftones, single-letter shortcuts, hex; +* percentage coordinates, `y` auto-positioning, rotation and the 1-bit palette. + +The preview renders the template first, so `{{ states('sensor.x') }}` is +resolved rather than drawn literally. **Zoom** scales the image with nearest +neighbour so individual pixels stay visible, and **Checkerboard** puts it on a +transparency grid — useful because an e-paper panel has no backlight. + +Anything the renderer cannot draw is reported instead of being silently +dropped: per-element **errors** (a missing key, an unknown icon name) and +**notes** for the two things that need a live Home Assistant — `plot` draws its +frame and axes but no data, and `dlimg` shows a labelled placeholder instead of +downloading a remote image. + +**Fonts** are not committed. `backend/scripts/fetch_assets.py` downloads them +and verifies each file against a pinned size *and* git blob SHA-1, so a moved +tag or a tampered mirror fails the build rather than shipping wrong glyphs. The +Docker build runs it automatically; for a local checkout run it once: + +```bash +cd backend +python scripts/fetch_assets.py # download what is missing +python scripts/fetch_assets.py --check # verify only +``` + ### Sending to a display -**📤 Send to display** calls the configured Home Assistant service (default -`open_epaper_link.drawcustom`) with the rendered template as `payload`: +**📤 Send to display** renders the template and calls the configured Home +Assistant service (default `open_epaper_link.drawcustom`): ```yaml service: open_epaper_link.drawcustom data: device_id: "0011223344556677" - payload: "{{ }}" + payload: + - type: text + value: "21.5 °C" + x: 40 + y: 46 + size: 22 background: white + rotate: 0 + dither: 2 + ttl: 60 ``` -Enable **Dry run** to have Home Assistant render the image without pushing it to -the tag. +The template is rendered through Home Assistant first (`POST /api/template`) +and the resulting **list** is sent as `payload`. This matters: the integration +reads `payload` as a finished list of elements and never renders Jinja itself, +and `/api/services` does not render templates inside `data` either — so sending +the raw template would reach the tag unrendered and fail. If the template does +not render to a valid payload, nothing is sent and the error is reported. + +**Rotate**, **Dither** (0 none, 1 Floyd-Steinberg, 2 ordered) and **TTL** are +set in the *Send to display* dialog and saved with the project. Enable +**Dry run** to have Home Assistant render the image without pushing it to the +tag. --- @@ -200,13 +300,29 @@ the tag. ├── backend/ │ ├── Dockerfile │ ├── requirements.txt +│ ├── scripts/ +│ │ └── fetch_assets.py # download + verify preview fonts │ └── app/ │ ├── main.py # FastAPI app + static hosting │ ├── schema.py # single source of truth for element types │ ├── generator.py # visual model -> Jinja / YAML / JSON +│ ├── templating.py # render + validate the generated template │ ├── storage.py # JSON-file project storage │ ├── ha_client.py # Home Assistant REST client -│ └── test_generator.py # generator tests +│ ├── render/ # pixel-accurate PNG renderer +│ │ ├── renderer.py # entry point + dispatch +│ │ ├── colors.py # drawcustom palette +│ │ ├── coordinates.py # pixels and percentages +│ │ ├── fonts.py # font + MDI metadata loading +│ │ ├── text.py # text, multiline, markup +│ │ ├── shapes.py # line, rect, polygon, circle, arc +│ │ ├── icons.py # icon, icon_sequence +│ │ ├── media.py # qrcode, dlimg +│ │ └── visualizations.py # progress_bar, plot +│ ├── test_generator.py # generator tests +│ ├── test_templating.py # render / validation tests +│ ├── test_render.py # preview renderer tests +│ └── test_api.py # HTTP / security tests └── frontend/ ├── index.html ├── css/styles.css @@ -216,6 +332,7 @@ the tag. ├── state.js # state store + node helpers ├── canvas.js # drag & drop, move, resize ├── geometry.js # bounding boxes per element type + ├── theme.js # colors + glyphs shared by the UI ├── renderer.js # on-canvas previews ├── inspector.js # schema-driven property editor ├── layers.js # layer tree @@ -235,8 +352,10 @@ and the generator. Adding a new draw type means adding one entry there. * Required properties are always emitted. * Optional properties are emitted only when they differ from the documented - default — so the output stays as short as a hand-written template. -* Values containing `{{` / `{%` are emitted unquoted so Jinja evaluates them. + default — so the output stays as short as a hand-written template. Properties + whose Home Assistant default is dynamic (a line's `y_end`, a plot's box, + `multiline.y`, …) are always emitted, so the display matches the canvas. +* Jinja in number fields is emitted bare, in text fields inside the string. * Properties whose documented default is `null` are omitted unless explicitly set. @@ -247,12 +366,36 @@ and the generator. Adding a new draw type means adding one entry there. ```bash cd backend pip install -r requirements-dev.txt -python -m app.test_generator +python -m pytest -q app +ruff check app ``` -The suite renders generated templates with a real Jinja environment and asserts -the result is valid JSON — covering the weather example, groups in first -position, hidden elements, numeric types and shapes. +`test_generator` renders generated templates with a real Jinja environment and +asserts the result is valid JSON — covering the weather example, repeat-group +comma placement, hidden elements, quoting, percentages and dynamic defaults. +`test_templating` covers the render sandbox (permissive stubs, sandbox escapes, +error reporting) and each validation stage, including the caret position in the +error excerpt. `test_render` checks the preview renderer by inspecting pixels: +colour resolution, coordinate parsing, rotation, per-element error handling and +that one broken element does not stop the rest from drawing. `test_api` covers +the HTTP layer: path traversal, host check, project ids, token handling and that +*Send to display* renders before sending. + +The preview tests skip automatically when the fonts have not been fetched; run +`python scripts/fetch_assets.py` first to include them. + +### CI + +`.github/workflows/ci.yml` runs four jobs on every push and pull request: + +| Job | What it checks | +| --- | --- | +| Backend | `ruff check app` and `python -m pytest -q app` | +| Frontend | `node --check` on every module, plus that each relative import resolves to a file that exists | +| Docker | the image builds (nothing is pushed) | + +Lint is pinned by `backend/ruff.toml` so a new ruff release cannot turn CI red +for reasons unrelated to a change. --- @@ -268,6 +411,8 @@ position, hidden elements, numeric types and shapes. | `PUT` | `/api/projects/{id}` | Update a project | | `DELETE` | `/api/projects/{id}` | Delete a project | | `POST` | `/api/generate/template` | Generate Jinja / YAML / JSON | +| `POST` | `/api/validate` | Render the template and check the payload | +| `POST` | `/api/preview` | Render a pixel-accurate PNG preview | | `GET` | `/api/settings` | Read settings (token redacted) | | `POST` | `/api/settings` | Save settings | | `GET` | `/api/ha/status` | Test the Home Assistant connection | @@ -282,12 +427,19 @@ Interactive docs are available at `/docs`. * Projects are stored as JSON files in the `epaper-gui-data` volume. Back it up if you care about the layouts. -* The canvas is a **design-time approximation**. Home Assistant and Pillow do - the real rendering, so exact font metrics and icon glyphs can differ slightly. - Use **Send to display → Dry run** to check the true result. -* Icons in the editor use the MDI webfont from a CDN. Offline, elements fall - back to a labelled placeholder — the generated payload is unaffected. +* The canvas is a **design-time approximation**. For the real thing use + **🖼 Pixel preview**, which renders with the actual drawing code, or + **Send to display → Dry run** to have Home Assistant produce the image. +* The pixel preview cannot fetch live data: a `plot` renders its frame but no + series, and a `dlimg` with a remote URL renders a placeholder. Both are + reported as notes above the image. +* Icons in the *editor* use the MDI webfont from a CDN. Offline, elements fall + back to a labelled placeholder — the generated payload and the pixel preview + are unaffected. * The QR preview is a placeholder; the real code is generated by Home Assistant. +* *Validation* falls back to a local sandbox when Home Assistant is unreachable. + Unresolved values render as `0`, so the check proves the template renders and + the payload is well formed — it does not confirm the values are meaningful. ## Security @@ -296,6 +448,16 @@ a Home Assistant long-lived token and can push to your displays, so treat it as a trusted-network tool: bind it to your LAN or put it behind a reverse proxy with auth, and do not expose the port to the internet. -Cross-origin requests are rejected by default. Only set `CORS_ORIGINS` if you -serve the frontend from a different host or port, and list exact origins — -without authentication, any allowed origin can act on your behalf. +Built-in guard rails: + +* Cross-origin requests are rejected by default. Only set `CORS_ORIGINS` if you + serve the frontend from a different host or port, and list exact origins — + without authentication, any allowed origin can act on your behalf. +* Requests whose `Host` header is not an IP address, `localhost`, a dot-less + name, a LAN suffix (`.local`, `.lan`, `.home.arpa`, …) or listed in + `ALLOWED_HOSTS` are rejected. This blocks DNS rebinding; behind a reverse + proxy with its own domain, add that domain to `ALLOWED_HOSTS`. +* The token is only ever sent to the URL it was configured for (see + *Configuration*), so pointing the editor at another server does not leak it. +* *Send to display* can only call services in `ALLOWED_SERVICE_DOMAINS` + (default `open_epaper_link`). diff --git a/backend/Dockerfile b/backend/Dockerfile index ee76dc0..ccc8723 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -12,10 +12,19 @@ COPY backend/requirements.txt ./requirements.txt RUN pip install --no-cache-dir -r requirements.txt COPY backend/app ./app +COPY backend/scripts ./scripts COPY frontend ./frontend +# Fonts for the pixel-accurate preview are fetched at build time rather than +# committed, so the repository stays free of ~4.5 MB of binaries. Each file is +# verified against its expected size and git blob SHA-1; a mismatch fails the +# build instead of shipping a wrong font. +RUN python scripts/fetch_assets.py + RUN mkdir -p /data EXPOSE 8000 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file +# Shell form wrapper so LOG_LEVEL is honoured; `exec` keeps uvicorn as PID 1 +# so it still receives SIGTERM from `docker compose down`. +CMD ["sh", "-c", "exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --log-level \"${LOG_LEVEL:-info}\""] \ No newline at end of file diff --git a/backend/app/generator.py b/backend/app/generator.py index b589be8..8c2e98c 100644 --- a/backend/app/generator.py +++ b/backend/app/generator.py @@ -14,8 +14,7 @@ "color": "yellow" } {% for i in range(8) %} - , - { + ,{ "type": "text", "value": "{{ times[i] }}", "x": {{ 15 + i*spacing }}, @@ -35,9 +34,10 @@ That only works while we know, before rendering, how many items each node produces. Nodes that emit nothing (hidden elements, empty or zero-iteration groups) are dropped up front so they cannot leave a dangling comma behind. When -an iteration count is itself a Jinja expression the item count is unknowable +an iteration count is itself a Jinja expression, or groups are nested (where +``loop.first`` only describes the innermost loop), the item count is not known here, so the template falls back to a runtime flag -(``{% set ns = namespace(first=true) %}``) that every item checks and clears. +(``{% set oepl_ns = namespace(first=true) %}``) that every item checks and clears. """ from __future__ import annotations @@ -46,15 +46,17 @@ import re from typing import Any -from .schema import ELEMENT_TYPES_BY_NAME, INTERNAL_FIELDS, REQUIRED_FIELDS +from .schema import ELEMENT_TYPES_BY_NAME, INTERNAL_FIELDS, emitted_always INDENT = " " _INTEGER_RE = re.compile(r"-?\d+") _NUMERIC_RE = re.compile(r"-?\d+(?:\.\d+)?") -_WHOLE_TAG_RE = re.compile(r"\{\{\s*(.+?)\s*\}\}", re.S) +# drawcustom accepts percentages for positions, e.g. `x: "50%"`. +_PERCENT_RE = re.compile(r"-?\d+(?:\.\d+)?%") +_WHOLE_TAG_RE = re.compile(r"\{\{\s*(.+?)\s*\}\}", re.DOTALL) # Splits a string into literal chunks and whole Jinja tags (odd indexes). -_JINJA_SEGMENT_RE = re.compile(r"(\{\{.*?\}\}|\{%.*?%\})", re.S) +_JINJA_SEGMENT_RE = re.compile(r"(\{\{.*?\}\}|\{%.*?%\})", re.DOTALL) # --------------------------------------------------------------------------- @@ -85,6 +87,10 @@ def _fmt_number(value: Any) -> str: if _NUMERIC_RE.fullmatch(text): # A numeric string, e.g. "42" typed into a number field. return text + if _PERCENT_RE.fullmatch(text): + # A relative position. It has to stay a JSON string: `{{ 50% }}` is a + # Jinja syntax error and a bare `50%` is invalid JSON. + return json.dumps(text) # A bare expression such as `spacing` or `15 + i*spacing`. Wrapping it is # what the user meant; emitting it raw would produce invalid JSON. return "{{ " + text + " }}" @@ -128,7 +134,7 @@ def _fmt_plotdata(entries: Any) -> str: continue inner = [] for key, value in entry.items(): - if value in (None, ""): + if value is None or value == "": continue if isinstance(value, bool): inner.append(f'"{key}": {"true" if value else "false"}') @@ -140,6 +146,44 @@ def _fmt_plotdata(entries: Any) -> str: return "[" + ", ".join(lines) + "]" +def _object_items(field: dict, value: Any) -> list[tuple[dict, Any]] | None: + """Sub-properties of an `object` field (plot axes / legends) to emit. + + `None` means the whole object is switched off and omitted. Home Assistant + treats an empty object as "off" as well, so when every sub-property equals + its default the field's `emit_key` is emitted anyway to keep it on. + """ + if not isinstance(value, dict): + return None + items = [] + for sub in field["fields"]: + raw = value.get(sub["key"], sub.get("default")) + if _same_as_default(raw, sub.get("default")): + continue + if raw is None or (isinstance(raw, str) and not raw.strip()): + continue + items.append((sub, raw)) + if not items: + key = field.get("emit_key") + sub = next((s for s in field["fields"] if s["key"] == key), None) + if sub is None or sub.get("default") is None: + return None + items.append((sub, value.get(key, sub["default"]))) + return items + + +def _fmt_object(field: dict, value: Any) -> str | None: + items = _object_items(field, value) + if items is None: + return None + parts = [] + for sub, raw in items: + formatted = _fmt_value(sub, raw) + if formatted is not None: + parts.append(f'"{sub["key"]}": {formatted}') + return "{" + ", ".join(parts) + "}" + + def _fmt_value(field: dict, value: Any) -> str | None: """Format a single property. Returns None when the property is omitted.""" kind = field["kind"] @@ -177,6 +221,9 @@ def _fmt_value(field: dict, value: Any) -> str | None: return None return _fmt_plotdata(value) + if kind == "object": + return _fmt_object(field, value) + # text / textarea if value is None or value == "": return None @@ -184,38 +231,71 @@ def _fmt_value(field: dict, value: Any) -> str | None: # --------------------------------------------------------------------------- -# Element emission +# Which properties an element emits # --------------------------------------------------------------------------- -def _element_lines(element: dict, indent: str) -> list[str]: - """Render one element as a list of JSON lines (without trailing comma).""" +def _forced_fields(element_type: str, props: dict) -> set[str]: + """Optional fields that must be emitted for this particular element. + + These mirror defaults in the OpenEPaperLink image generator that depend on + other properties, so "equal to the schema default" is not the same as + "equal to what Home Assistant would use". + """ + forced: set[str] = set() + if element_type == "text": + # Without `anchor`, HA uses "la" for multi-line text and "lt" otherwise. + # Wrapping (`max_width`) can introduce line breaks at render time. + value = props.get("value") + max_width = props.get("max_width") + if (isinstance(value, str) and "\n" in value) or max_width not in (None, ""): + forced.add("anchor") + if element_type in ("rectangle", "rectangle_pattern"): + # HA rounds with radius 10 when `corners` is given without `radius`. + corners = props.get("corners") + spec = ELEMENT_TYPES_BY_NAME[element_type] + default = next((f["default"] for f in spec["fields"] if f["key"] == "corners"), None) + if corners not in (None, "") and corners != default: + forced.add("radius") + return forced + + +def _emitted_fields(element: dict) -> list[tuple[dict, Any]] | None: + """(field, value) pairs an element emits, or None when it emits nothing.""" element_type = element.get("type") spec = ELEMENT_TYPES_BY_NAME.get(element_type) if not spec: - return [] + return None props = element.get("props") or {} if props.get("visible") is False: - return [] - - required = REQUIRED_FIELDS.get(element_type, set()) + return None - pairs: list[tuple[str, str]] = [("type", _fmt_string(element_type))] + always = emitted_always(element_type) | _forced_fields(element_type, props) + out = [] for field in spec["fields"]: key = field["key"] if key in INTERNAL_FIELDS or key == "type": continue raw = props.get(key, field.get("default")) - default = field.get("default") - # Only emit optional properties when they differ from the default. - if key not in required and _same_as_default(raw, default): + if key not in always and _same_as_default(raw, field.get("default")): continue + out.append((field, raw)) + return out + +def _element_lines(element: dict, indent: str) -> list[str]: + """Render one element as a list of JSON lines (without trailing comma).""" + fields = _emitted_fields(element) + if fields is None: + return [] + + pairs: list[tuple[str, str]] = [("type", _fmt_string(element["type"]))] + for field, raw in fields: formatted = _fmt_value(field, raw) if formatted is None: continue - pairs.append((key, formatted)) + pairs.append((field["key"], formatted)) lines = [indent + "{"] for index, (key, formatted) in enumerate(pairs): @@ -240,24 +320,25 @@ def _same_as_default(value: Any, default: Any) -> bool: return False -def _indent_block(lines: list[str], extra: str) -> list[str]: - return [extra + line if line.strip() else line for line in lines] - - # --------------------------------------------------------------------------- # Node emission # --------------------------------------------------------------------------- # Comma placement is decided statically whenever we know how many payload items -# every node produces. A Jinja-driven iteration count breaks that, so the -# template then switches to a runtime flag for the whole payload instead. -GUARD_INIT = "{% set ns = namespace(first=true) %}" -GUARD_PREFIX = "{% if not ns.first %},{% endif %}{% set ns.first = false %}" +# every node produces. A Jinja-driven iteration count or nested groups break +# that, so the template then switches to a runtime flag for the whole payload. +# The name is prefixed so it cannot collide with a user's own `ns`. +GUARD_INIT = "{% set oepl_ns = namespace(first=true) %}" +GUARD_PREFIX = "{% if not oepl_ns.first %},{% endif %}{% set oepl_ns.first = false %}" # First item of the payload, emitted inside a loop: comma from iteration 2 on. LOOP_FIRST_PREFIX = "{% if not loop.first %},{% endif %}" +def _is_group(node: dict) -> bool: + return node.get("kind") == "group" + + def _static_count(count: Any) -> int | None: """Iteration count when it is a plain integer, else None (Jinja decides).""" if isinstance(count, bool): @@ -282,27 +363,35 @@ def _count_expr(count: Any) -> str: return match.group(1) if match else text -def _emittable(children: list) -> list: - return [child for child in children if _element_lines(child, INDENT)] - - def _node_item_count(node: dict) -> int | None: """How many payload items a node produces, or None when only Jinja knows.""" - if node.get("kind") != "group": - return 1 if _element_lines(node, INDENT) else 0 + if not _is_group(node): + return 1 if _emitted_fields(node) is not None else 0 - children = _emittable(node.get("children") or []) - if not children: + total = 0 + for child in node.get("children") or []: + count = _node_item_count(child) + if count is None: + return None + total += count + if total == 0: return 0 repeat = node.get("repeat") or {} if not repeat.get("enabled", True): - return len(children) + return total count = _static_count(repeat.get("count", 1)) if count is None: return None - return max(0, count) * len(children) + return max(0, count) * total + + +def _has_nested_groups(nodes: list) -> bool: + return any( + _is_group(node) and any(_is_group(child) for child in node.get("children") or []) + for node in nodes + ) or any(_has_nested_groups(node.get("children") or []) for node in nodes if _is_group(node)) def _prefix(first: bool, seen: bool, guard: bool, in_loop: bool) -> str: @@ -327,21 +416,35 @@ def _emit_element(element: dict, prefix: str, indent: str) -> list[str]: return body +def _emit_children(children: list, first: bool, indent: str, guard: bool, in_loop: bool) -> list[str]: + out: list[str] = [] + seen = False + for child in children: + # A child that emits nothing (hidden, unknown type, empty group) must + # not consume the "no comma yet" slot, or the next one emits a stray + # comma. + if _node_item_count(child) == 0: + continue + if _is_group(child): + # Nested groups always run with the runtime guard (see module doc), + # so `first` / `seen` no longer matter here. + block = _emit_group(child, first and not seen, indent, guard) + else: + block = _emit_element(child, _prefix(first, seen, guard, in_loop), indent) + if not block: + continue + seen = True + out.extend(block) + return out + + def _emit_group(group: dict, first: bool, indent: str, guard: bool) -> list[str]: children = group.get("children") or [] repeat = group.get("repeat") or {} if not repeat.get("enabled", True): # Emit the children once, as plain elements. - out: list[str] = [] - seen = False - for child in children: - block = _emit_element(child, _prefix(first, seen, guard, False), indent) - if not block: - continue - seen = True - out.extend(block) - return out + return _emit_children(children, first, indent, guard, in_loop=False) lines: list[str] = [] @@ -354,27 +457,16 @@ def _emit_group(group: dict, first: bool, indent: str, guard: bool) -> list[str] statement = "{% set " + statement + " %}" lines.append(indent + statement) - var = repeat.get("var") or "i" + var = str(repeat.get("var") or "i").strip() or "i" count = _count_expr(repeat.get("count", 1)) lines.append(indent + "{% for " + var + " in range(" + count + ") %}") - - body_indent = indent + INDENT - seen = False - for child in children: - # A child that emits nothing (hidden, unknown type) must not consume - # the "no comma yet" slot, or the next one emits a stray comma. - block = _emit_element(child, _prefix(first, seen, guard, True), body_indent) - if not block: - continue - seen = True - lines.extend(block) - + lines.extend(_emit_children(children, first, indent + INDENT, guard, in_loop=True)) lines.append(indent + "{% endfor %}") return lines def _emit_node(node: dict, first: bool, indent: str, guard: bool) -> list[str]: - if node.get("kind") == "group": + if _is_group(node): return _emit_group(node, first, indent, guard) return _emit_element(node, _prefix(first, False, guard, False), indent) @@ -402,7 +494,8 @@ def generate_template(project: dict) -> str: counts = [_node_item_count(node) for node in nodes] # One unknown count anywhere makes static comma placement unsafe, because a # node that emits nothing at render time would leave a dangling comma. - guard = any(count is None for count in counts) + # Nested loops break `loop.first` the same way. + guard = any(count is None for count in counts) or _has_nested_groups(nodes) if guard: lines.append(GUARD_INIT) @@ -412,7 +505,7 @@ def generate_template(project: dict) -> str: lines.append("[") emitted_any = False - for node, count in zip(nodes, counts): + for node, count in zip(nodes, counts, strict=True): if count == 0: continue # emits nothing, so it must not affect comma placement block = _emit_node(node, not emitted_any, INDENT, guard) @@ -425,6 +518,39 @@ def generate_template(project: dict) -> str: return "\n".join(lines) +def collect_warnings(project: dict) -> list[str]: + """Things in the project the generator cannot express, or likely mistakes.""" + warnings: list[str] = [] + + def check(node: dict, loop_vars: tuple[str, ...]) -> None: + if _is_group(node): + repeat = node.get("repeat") or {} + inner = loop_vars + if repeat.get("enabled", True): + var = str(repeat.get("var") or "i").strip() or "i" + if var in loop_vars: + warnings.append( + f"Group \"{node.get('name') or 'group'}\" reuses the loop variable " + f"\"{var}\" of an enclosing group; the outer value is hidden inside it." + ) + inner = loop_vars + (var,) + for child in node.get("children") or []: + check(child, inner) + elif node.get("type") not in ELEMENT_TYPES_BY_NAME: + warnings.append(f"Unknown element type \"{node.get('type')}\" is skipped.") + + for node in project.get("nodes") or []: + check(node, ()) + return warnings + + +def _static_value(field: dict, value: Any) -> Any: + if field["kind"] == "object": + items = _object_items(field, value) + return None if items is None else {sub["key"]: raw for sub, raw in items} + return value + + def generate_payload(project: dict) -> list[dict]: """Generate the *static* payload, ignoring repeat groups. @@ -433,36 +559,28 @@ def generate_payload(project: dict) -> list[dict]: payload: list[dict] = [] def emit(element: dict) -> None: - spec = ELEMENT_TYPES_BY_NAME.get(element.get("type")) - if not spec: + fields = _emitted_fields(element) + if fields is None: return - props = element.get("props") or {} - if props.get("visible") is False: - return - required = REQUIRED_FIELDS.get(element["type"], set()) item: dict[str, Any] = {"type": element["type"]} - for field in spec["fields"]: - key = field["key"] - if key in INTERNAL_FIELDS or key == "type": - continue - value = props.get(key, field.get("default")) - if key not in required and _same_as_default(value, field.get("default")): - continue + for field, raw in fields: + value = _static_value(field, raw) if value is None or value == "": continue - item[key] = value + item[field["key"]] = value payload.append(item) - for node in project.get("nodes") or []: - if node.get("kind") == "group": - repeat = node.get("repeat") or {} - if repeat.get("enabled", True): - continue # dynamic, cannot be resolved statically - for child in node.get("children") or []: - emit(child) - else: - emit(node) + def walk(nodes: list) -> None: + for node in nodes: + if _is_group(node): + repeat = node.get("repeat") or {} + if repeat.get("enabled", True): + continue # dynamic, cannot be resolved statically + walk(node.get("children") or []) + else: + emit(node) + walk(project.get("nodes") or []) return payload @@ -508,7 +626,6 @@ def _scalar(value: Any) -> str: return "true" if value else "false" if isinstance(value, (int, float)): return _fmt_number(value) - text = str(value) - if _is_template(text): - return text - return json.dumps(text, ensure_ascii=False) \ No newline at end of file + # Always quote: a bare `{{ … }}` starts a YAML flow mapping, and values + # like `50%`, `yes` or `#fff` would change type or become comments. + return json.dumps(str(value), ensure_ascii=False) diff --git a/backend/app/ha_client.py b/backend/app/ha_client.py index 10ab37a..bf96023 100644 --- a/backend/app/ha_client.py +++ b/backend/app/ha_client.py @@ -69,4 +69,16 @@ async def call_service(self, domain: str, service: str, data: dict) -> list: try: return response.json() except ValueError: - return [] \ No newline at end of file + return [] + + async def render_template(self, template: str) -> str: + """Render a Jinja template through Home Assistant. + + ``POST /api/template`` returns rendered text, so this is the only way to + evaluate a template against live entity state. The REST service + endpoint does not do it for templates inside ``data``. + """ + response = await self._request( + "POST", "/api/template", json={"template": template} + ) + return response.text diff --git a/backend/app/main.py b/backend/app/main.py index 9e3920d..a5eac7f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,19 +2,29 @@ from __future__ import annotations +import base64 +import ipaddress import os from pathlib import Path +from typing import Literal -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse, JSONResponse +from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field from . import storage -from .generator import generate_payload, generate_template, generate_yaml +from .generator import ( + collect_warnings, + generate_payload, + generate_template, + generate_yaml, +) from .ha_client import HomeAssistantClient, HomeAssistantError +from .render import RenderError, assets_available, render_payload from .schema import default_props, public_schema +from .templating import payload_from_rendered, validate_project FRONTEND_DIR = Path( os.environ.get("FRONTEND_DIR") @@ -41,25 +51,83 @@ ) +def _env_list(name: str) -> list[str]: + return [item.strip().lower() for item in os.environ.get(name, "").split(",") if item.strip()] + + +# DNS rebinding protection. CORS does not help against a malicious page whose +# own hostname is re-pointed at this server: the browser then treats the app +# as same-origin. Such requests always carry the attacker's hostname in `Host`, +# so only accept hosts that cannot be attacker-controlled names: IP literals, +# `localhost`, typical LAN suffixes and whatever is listed in ALLOWED_HOSTS. +# `ALLOWED_HOSTS=*` switches the check off. +ALLOWED_HOSTS = _env_list("ALLOWED_HOSTS") +_LAN_SUFFIXES = (".local", ".lan", ".home", ".internal", ".home.arpa", ".localdomain") + + +def host_allowed(host_header: str) -> bool: + if "*" in ALLOWED_HOSTS: + return True + host = (host_header or "").strip().lower() + if host.startswith("["): # [::1]:8099 + host = host[1:].split("]", 1)[0] + elif host.count(":") == 1: + host = host.rsplit(":", 1)[0] + if not host: + return False + try: + ipaddress.ip_address(host) + return True + except ValueError: + pass + if host == "localhost" or "." not in host or host.endswith(_LAN_SUFFIXES): + return True + for allowed in ALLOWED_HOSTS: + if allowed.startswith("*.") and host.endswith(allowed[1:]): + return True + if host == allowed: + return True + return False + + +@app.middleware("http") +async def check_host(request: Request, call_next): + if not host_allowed(request.headers.get("host", "")): + return PlainTextResponse( + "Host not allowed. Add it to the ALLOWED_HOSTS environment variable.", + status_code=400, + ) + return await call_next(request) + + +# Services the push endpoint may call with the stored token. Without a limit, +# anyone who can reach the editor could call *any* Home Assistant service. +ALLOWED_SERVICE_DOMAINS = _env_list("ALLOWED_SERVICE_DOMAINS") or ["open_epaper_link"] + + # --------------------------------------------------------------------------- # Models # --------------------------------------------------------------------------- class Project(BaseModel): - id: str | None = None - name: str = "Untitled" - width: int = 296 - height: int = 128 - background: str = "white" - nodes: list[dict] = Field(default_factory=list) - variables: list[dict] = Field(default_factory=list) + id: str | None = Field(default=None, max_length=64) + name: str = Field(default="Untitled", max_length=200) + width: int = Field(default=296, ge=1, le=4096) + height: int = Field(default=128, ge=1, le=4096) + background: str = Field(default="white", max_length=32) + nodes: list[dict] = Field(default_factory=list, max_length=2000) + variables: list[dict] = Field(default_factory=list, max_length=500) + # drawcustom service options, sent with "Send to display". + rotate: Literal[0, 90, 180, 270] = 0 + dither: Literal[0, 1, 2] = 2 + ttl: int = Field(default=60, ge=0, le=86400) class Settings(BaseModel): - haUrl: str | None = None - haToken: str | None = None - deviceId: str | None = None - service: str | None = None + haUrl: str | None = Field(default=None, max_length=500) + haToken: str | None = Field(default=None, max_length=2000) + deviceId: str | None = Field(default=None, max_length=200) + service: str | None = Field(default=None, max_length=200) class PushRequest(BaseModel): @@ -70,6 +138,19 @@ class PushRequest(BaseModel): dryRun: bool = False +class PreviewRequest(BaseModel): + """A preview request: either a project or an explicit payload. + + ``payload`` lets the editor preview exactly what it has on the canvas + without a round trip through the generator; ``project`` is rendered through + the generator so a preview can also be requested for a saved project. + """ + + project: Project | None = None + payload: list[dict] | None = Field(default=None, max_length=2000) + accent: Literal["red", "yellow"] = "red" + + # --------------------------------------------------------------------------- # Schema # --------------------------------------------------------------------------- @@ -88,8 +169,10 @@ async def schema() -> dict: async def schema_defaults(element_type: str) -> dict: try: return {"type": element_type, "props": default_props(element_type)} - except KeyError: - raise HTTPException(status_code=404, detail=f"Unknown type: {element_type}") + except KeyError as exc: + raise HTTPException( + status_code=404, detail=f"Unknown type: {element_type}" + ) from exc # --------------------------------------------------------------------------- @@ -116,6 +199,11 @@ async def create_project(project: Project) -> dict: @app.put("/api/projects/{project_id}") async def update_project(project_id: str, project: Project) -> dict: + if not storage.is_valid_id(project_id): + raise HTTPException(status_code=400, detail="Invalid project id") + if not storage.project_exists(project_id): + # Create with POST; a PUT to a deleted project must not resurrect it. + raise HTTPException(status_code=404, detail="Project not found") data = project.model_dump() data["id"] = project_id return storage.save_project(data) @@ -139,6 +227,105 @@ async def generate_template_endpoint(project: Project) -> dict: "template": generate_template(data), "yaml": generate_yaml(data), "payload": generate_payload(data), + "warnings": collect_warnings(data), + } + + +@app.post("/api/validate") +async def validate_endpoint(project: Project) -> dict: + """Render the generated template and check the payload it produces. + + Uses Home Assistant when it is reachable so the check reflects live entity + state, and falls back to a local sandbox otherwise. The response reports the + failing stage (render / parse / elements) plus an excerpt and a list of + per-element problems. + """ + settings = storage.effective_settings() + client: HomeAssistantClient | None = None + if settings.get("haUrl") and settings.get("haToken"): + client = HomeAssistantClient(settings["haUrl"], settings["haToken"]) + + result = await validate_project(project.model_dump(), client) + # The template is large and already available from /api/generate/template. + result.pop("template", None) + if result.get("payload") is not None: + result["count"] = len(result["payload"]) + result.pop("payload", None) + return result + + +@app.post("/api/preview") +async def preview_endpoint(request: PreviewRequest) -> dict: + """Render a payload to a PNG with the real drawing code. + + Unlike the canvas (a design-time approximation), this uses the same fonts, + colours, coordinates and Pillow calls the integration uses, so the result is + pixel-accurate. + + A project is run through the generator *and then rendered* before drawing, + because the payload contains Jinja expressions (`{{ states('sensor.x') }}`) + that would otherwise reach the renderer as literal strings. Rendering uses + Home Assistant when configured and the local sandbox otherwise. + + Returns the image as a data URI along with per-element ``errors`` and + ``notes``. Problems do not fail the request: a preview that shows fifteen + correct elements and one broken one is more useful than an error. + """ + if not assets_available(): + raise HTTPException( + status_code=503, + detail="Font assets are not available. Run backend/scripts/fetch_assets.py " + "or rebuild the Docker image.", + ) + + width, height = 296, 128 + background, rotate = "white", 0 + render_notes: list[str] = [] + + if request.project is not None: + project = request.project.model_dump() + background = project.get("background", "white") + rotate = project.get("rotate", 0) + width, height = project["width"], project["height"] + + settings = storage.effective_settings() + client: HomeAssistantClient | None = None + if settings.get("haUrl") and settings.get("haToken"): + client = HomeAssistantClient(settings["haUrl"], settings["haToken"]) + + check = await validate_project(project, client) + if not check["ok"]: + raise HTTPException( + status_code=400, + detail=f"The template must render before it can be previewed: {check['error']}", + ) + payload = check["payload"] + if check.get("haError"): + render_notes.append(f"rendered locally, not by Home Assistant: {check['haError']}") + elif request.payload is not None: + payload = request.payload + else: + raise HTTPException(status_code=400, detail="Provide either 'project' or 'payload'.") + + try: + png, errors, notes = render_payload( + payload, + width=width, + height=height, + accent=request.accent, + background=background, + rotate=rotate, + ) + except RenderError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + return { + "image": "data:image/png;base64," + base64.b64encode(png).decode("ascii"), + "width": width, + "height": height, + "elements": len(payload), + "errors": errors, + "notes": [*render_notes, *notes], } @@ -154,8 +341,11 @@ async def get_settings() -> dict: @app.post("/api/settings") async def update_settings(settings: Settings) -> dict: payload = {k: v for k, v in settings.model_dump().items() if v is not None} - storage.save_settings(payload) - return storage.public_settings() + url = (payload.get("haUrl") or "").strip() + if url and not url.lower().startswith(("http://", "https://")): + raise HTTPException(status_code=400, detail="Home Assistant URL must start with http:// or https://") + saved = storage.save_settings(payload) + return {**storage.public_settings(), "tokenCleared": saved["tokenCleared"]} # --------------------------------------------------------------------------- @@ -180,7 +370,7 @@ async def ha_entities(domain: str | None = None) -> list[dict]: client = HomeAssistantClient(settings["haUrl"], settings["haToken"]) return await client.list_entities(domain) except HomeAssistantError as exc: - raise HTTPException(status_code=502, detail=str(exc)) + raise HTTPException(status_code=502, detail=str(exc)) from exc @app.post("/api/ha/push") @@ -194,25 +384,60 @@ async def ha_push(request: PushRequest) -> dict: if "." not in service: raise HTTPException(status_code=400, detail=f"Invalid service: {service}") domain, service_name = service.split(".", 1) + if domain.lower() not in ALLOWED_SERVICE_DOMAINS: + raise HTTPException( + status_code=400, + detail=f"Service domain '{domain}' is not allowed " + "(see ALLOWED_SERVICE_DOMAINS).", + ) project = request.project.model_dump() template = generate_template(project) + try: + client = HomeAssistantClient(settings["haUrl"], settings["haToken"]) + except HomeAssistantError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + # `imagegen/core.py` reads `payload` as a finished list of elements and never + # renders Jinja itself, and /api/services does not render templates inside + # `data` either. Rendering through Home Assistant first is therefore the only + # way the tag receives a usable payload. + try: + rendered = await client.render_template(template) + except HomeAssistantError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + payload, check = payload_from_rendered(rendered) + if payload is None: + raise HTTPException( + status_code=400, + detail=f"Generated template did not render to a valid payload: {check['error']}", + ) + data = { "device_id": device_id, - "payload": template, + "payload": payload, "background": request.background or project.get("background", "white"), + "rotate": project["rotate"], + "dither": project["dither"], + "ttl": project["ttl"], } if request.dryRun: data["dry-run"] = True try: - client = HomeAssistantClient(settings["haUrl"], settings["haToken"]) result = await client.call_service(domain, service_name, data) except HomeAssistantError as exc: - raise HTTPException(status_code=502, detail=str(exc)) + raise HTTPException(status_code=502, detail=str(exc)) from exc - return {"ok": True, "service": service, "deviceId": device_id, "result": result} + return { + "ok": True, + "service": service, + "deviceId": device_id, + "elements": len(payload), + "result": result, + } # --------------------------------------------------------------------------- @@ -253,4 +478,4 @@ async def index_missing() -> JSONResponse: host="0.0.0.0", port=int(os.environ.get("PORT", "8000")), reload=True, - ) \ No newline at end of file + ) diff --git a/backend/app/render/__init__.py b/backend/app/render/__init__.py new file mode 100644 index 0000000..0c66e95 --- /dev/null +++ b/backend/app/render/__init__.py @@ -0,0 +1,27 @@ +"""Pixel-accurate preview rendering of drawcustom payloads. + +Public entry point is :func:`render_payload`, which returns PNG bytes plus any +per-element errors and notes about what could not be shown. +""" + +from __future__ import annotations + +# Importing these registers every handler with the dispatcher. +from . import icons, media, shapes, text, visualizations # noqa: E402,F401 +from .colors import ColorResolver +from .coordinates import Coordinates, parse_dimension +from .fonts import assets_available, icon_glyph, load_font, mdi_codepoints +from .renderer import DrawContext, RenderError, render_payload + +__all__ = [ + "ColorResolver", + "Coordinates", + "DrawContext", + "RenderError", + "assets_available", + "icon_glyph", + "load_font", + "mdi_codepoints", + "parse_dimension", + "render_payload", +] diff --git a/backend/app/render/colors.py b/backend/app/render/colors.py new file mode 100644 index 0000000..550fa00 --- /dev/null +++ b/backend/app/render/colors.py @@ -0,0 +1,88 @@ +"""Color resolution for the preview renderer. + +Mirrors the palette the OpenEPaperLink integration draws with so a preview +matches what the tag will show: named colors, the `half_*` halftones, single +letter shortcuts and hex values, with `accent` resolving to the tag's own +accent color. +""" + +from __future__ import annotations + +from typing import TypeAlias + +RGBA: TypeAlias = tuple[int, int, int, int] + +WHITE: RGBA = (255, 255, 255, 255) +BLACK: RGBA = (0, 0, 0, 255) +HALF_BLACK: RGBA = (127, 127, 127, 255) +RED: RGBA = (255, 0, 0, 255) +HALF_RED: RGBA = (255, 127, 127, 255) +YELLOW: RGBA = (255, 255, 0, 255) +HALF_YELLOW: RGBA = (255, 255, 127, 255) + +_NAMED: dict[str, RGBA] = { + "black": BLACK, "b": BLACK, + "white": WHITE, "w": WHITE, + "red": RED, "r": RED, + "yellow": YELLOW, "y": YELLOW, + "half_black": HALF_BLACK, "hb": HALF_BLACK, + "half_white": HALF_BLACK, "hw": HALF_BLACK, + "gray": HALF_BLACK, "grey": HALF_BLACK, "g": HALF_BLACK, + "half_red": HALF_RED, "hr": HALF_RED, + "half_yellow": HALF_YELLOW, "hy": HALF_YELLOW, +} + +_ACCENT_KEYS = {"accent", "a"} +_HALF_ACCENT_KEYS = {"half_accent", "ha"} + + +class ColorResolver: + """Turns a drawcustom color value into an RGBA tuple. + + Unknown names resolve to white, matching the integration. `accent` follows + the tag: red or yellow, chosen by the caller. + """ + + def __init__(self, accent: str = "red") -> None: + self.accent = "yellow" if str(accent).lower() == "yellow" else "red" + + def resolve(self, color: object) -> RGBA | None: + """Resolve a color. ``None`` stays ``None`` (meaning "no fill").""" + if color is None: + return None + + text = str(color).strip().lower() + if not text: + return None + + if text.startswith("#"): + return self._parse_hex(text[1:]) + + if text in _ACCENT_KEYS: + return YELLOW if self.accent == "yellow" else RED + if text in _HALF_ACCENT_KEYS: + return HALF_YELLOW if self.accent == "yellow" else HALF_RED + + return _NAMED.get(text, WHITE) + + @staticmethod + def _parse_hex(value: str) -> RGBA: + """Parse `#RGB` or `#RRGGBB` (the `#` already stripped).""" + try: + if len(value) == 3: + return ( + int(value[0] * 2, 16), + int(value[1] * 2, 16), + int(value[2] * 2, 16), + 255, + ) + if len(value) == 6: + return ( + int(value[0:2], 16), + int(value[2:4], 16), + int(value[4:6], 16), + 255, + ) + except ValueError: + pass + return WHITE diff --git a/backend/app/render/coordinates.py b/backend/app/render/coordinates.py new file mode 100644 index 0000000..3341f52 --- /dev/null +++ b/backend/app/render/coordinates.py @@ -0,0 +1,53 @@ +"""Coordinate parsing for the preview renderer. + +Positions may be absolute pixels or percentages of the canvas, and `int()` +truncation is applied the same way the integration does it so a preview lines up +with the tag to the pixel. +""" + +from __future__ import annotations + +Number: type = int | float + + +def parse_dimension(value: object, total: int) -> int: + """Convert a dimension to absolute pixels. + + Percentages are relative to ``total``; anything unparseable becomes 0. + """ + if isinstance(value, bool): + return 0 + if isinstance(value, (int, float)): + return int(value) + + text = str(value).strip() + if not text: + return 0 + + if text.endswith("%"): + try: + return int((float(text[:-1]) / 100) * total) + except ValueError: + return 0 + + try: + return int(float(text)) + except ValueError: + return 0 + + +class Coordinates: + """Parses x/y/size values against a canvas size.""" + + def __init__(self, width: int, height: int) -> None: + self.width = width + self.height = height + + def x(self, value: object) -> int: + return parse_dimension(value, self.width) + + def y(self, value: object) -> int: + return parse_dimension(value, self.height) + + def size(self, value: object, *, is_width: bool = True) -> int: + return parse_dimension(value, self.width if is_width else self.height) diff --git a/backend/app/render/fonts.py b/backend/app/render/fonts.py new file mode 100644 index 0000000..7de2cdd --- /dev/null +++ b/backend/app/render/fonts.py @@ -0,0 +1,91 @@ +"""Font loading for the preview renderer. + +Loads the bundled fonts and the Material Design Icons webfont, and resolves MDI +names to their codepoints via the shipped metadata. Everything is cached: the +metadata file is 3 MB of JSON and re-reading it per element would dominate the +render time. +""" + +from __future__ import annotations + +import json +from functools import lru_cache +from pathlib import Path + +from PIL import ImageFont + +ASSETS_DIR = Path(__file__).resolve().parents[1] / "assets" + +DEFAULT_FONT = "ppb.ttf" +MDI_FONT_FILE = "materialdesignicons-webfont.ttf" +MDI_META_FILE = "materialdesignicons-webfont_meta.json" + +# Bounded so a preview with many sizes cannot grow the cache without limit. +_FONT_CACHE_SIZE = 64 + + +class FontUnavailable(RuntimeError): + """Raised when a requested font file is not present in the image.""" + + +def assets_available() -> bool: + """Whether the font assets have been fetched.""" + return (ASSETS_DIR / DEFAULT_FONT).exists() and (ASSETS_DIR / MDI_FONT_FILE).exists() + + +@lru_cache(maxsize=_FONT_CACHE_SIZE) +def load_font(name: str, size: int) -> ImageFont.FreeTypeFont: + """Load a font at a pixel size. + + Falls back to the default font when the requested file is missing, matching + the integration's behaviour. + """ + size = max(1, int(size)) + path = ASSETS_DIR / str(name) + if not path.exists(): + path = ASSETS_DIR / DEFAULT_FONT + if not path.exists(): + raise FontUnavailable( + f"Font assets are missing from {ASSETS_DIR}. " + "Run backend/scripts/fetch_assets.py or rebuild the Docker image." + ) + return ImageFont.truetype(str(path), size) + + +@lru_cache(maxsize=1) +def mdi_codepoints() -> dict[str, str]: + """Map every MDI name and alias to its codepoint (hex string, no `0x`).""" + meta_path = ASSETS_DIR / MDI_META_FILE + if not meta_path.exists(): + return {} + + try: + entries = json.loads(meta_path.read_text("utf-8")) + except (json.JSONDecodeError, OSError): + return {} + + mapping: dict[str, str] = {} + for entry in entries: + codepoint = entry.get("codepoint") + name = entry.get("name") + if not codepoint or not name: + continue + mapping[name] = codepoint + for alias in entry.get("aliases") or (): + # A name always wins over an alias, so only fill gaps. + mapping.setdefault(alias, codepoint) + return mapping + + +def icon_glyph(value: object) -> str | None: + """Resolve an icon name (`mdi:home`, `home`) to its glyph character.""" + name = str(value or "").strip() + if name.startswith("mdi:"): + name = name[4:] + codepoint = mdi_codepoints().get(name) + if not codepoint: + return None + try: + return chr(int(codepoint, 16)) + except ValueError: + return None diff --git a/backend/app/render/icons.py b/backend/app/render/icons.py new file mode 100644 index 0000000..3020625 --- /dev/null +++ b/backend/app/render/icons.py @@ -0,0 +1,86 @@ +"""Icon handlers for the preview renderer. + +Icons are drawn from the Material Design Icons webfont using the codepoints in +the shipped metadata, exactly like the integration does. An unknown name is +skipped with a note rather than aborting the preview. +""" + +from __future__ import annotations + +from .fonts import icon_glyph, load_font +from .text import handler + + +def _draw_glyph(ctx, element: dict, glyph: str, x: int, y: int, size: int, fill) -> tuple: + """Draw one MDI glyph and return its bounding box.""" + font = load_font("materialdesignicons-webfont.ttf", size) + anchor = element.get("anchor", "la") + stroke_width = int(element.get("stroke_width", 0)) + stroke_fill = ctx.colors.resolve(element.get("stroke_fill", "white")) + ctx.draw.text( + (x, y), + glyph, + fill=fill, + font=font, + anchor=anchor, + stroke_width=stroke_width, + stroke_fill=stroke_fill, + ) + return ctx.draw.textbbox((x, y), glyph, font=font, anchor=anchor) + + +@handler("icon") +def draw_icon(ctx, element: dict) -> None: + ctx.draw.fontmode = "1" + + x = ctx.coords.x(element["x"]) + y = ctx.coords.y(element["y"]) + size = int(element["size"]) + # The integration accepts either `color` or `fill` here. + fill = ctx.colors.resolve(element.get("color") or element.get("fill", "black")) + + glyph = icon_glyph(element["value"]) + if glyph is None: + raise KeyError(f"unknown icon name '{element['value']}'") + + bbox = _draw_glyph(ctx, element, glyph, x, y, size, fill) + ctx.pos_y = bbox[3] + + +@handler("icon_sequence") +def draw_icon_sequence(ctx, element: dict) -> None: + ctx.draw.fontmode = "1" + + x_start = ctx.coords.x(element["x"]) + y_start = ctx.coords.y(element["y"]) + size = int(element["size"]) + # Default spacing is a quarter of the icon size. + spacing = element.get("spacing") + spacing = size // 4 if spacing is None else int(spacing) + fill = ctx.colors.resolve(element.get("fill", "black")) + direction = element.get("direction", "right") + + current_x, current_y = x_start, y_start + max_x, max_y = x_start, y_start + + for name in element["icons"]: + glyph = icon_glyph(name) + if glyph is None: + ctx.errors.append(f"Icon sequence: unknown icon name '{name}'") + continue + + bbox = _draw_glyph(ctx, element, glyph, current_x, current_y, size, fill) + max_x = max(max_x, bbox[2]) + max_y = max(max_y, bbox[3]) + + step = size + spacing + if direction == "right": + current_x += step + elif direction == "left": + current_x -= step + elif direction == "down": + current_y += step + elif direction == "up": + current_y -= step + + ctx.pos_y = max(max_y, current_y) diff --git a/backend/app/render/media.py b/backend/app/render/media.py new file mode 100644 index 0000000..4c17261 --- /dev/null +++ b/backend/app/render/media.py @@ -0,0 +1,173 @@ +"""Media handlers for the preview renderer: QR codes and images. + +QR codes are generated locally with the same parameters the integration uses +(version 1, high error correction). Images that live outside the editor — a +remote URL, an entity picture — cannot be fetched here, so they render as a +labelled placeholder that keeps the layout visible. +""" + +from __future__ import annotations + +import base64 +import io +import urllib.parse +from pathlib import Path + +from PIL import Image + +from .text import UnsupportedElement, handler + + +@handler("qrcode") +def draw_qrcode(ctx, element: dict) -> None: + import qrcode + + x = ctx.coords.x(element["x"]) + y = ctx.coords.y(element["y"]) + color = ctx.colors.resolve(element.get("color", "black")) + background = ctx.colors.resolve(element.get("bgcolor", "white")) + border = int(element.get("border", 1)) + box_size = int(element.get("boxsize", 2)) + + code = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_H, + box_size=box_size, + border=border, + ) + code.add_data(str(element["data"])) + code.make(fit=True) + + # qrcode wants plain RGB; the RGBA tuples are trimmed to their first three + # components exactly as the integration does. + image = code.make_image(fill_color=color[:3], back_color=background[:3]).convert("RGBA") + + ctx.image.paste(image, (x, y), image) + ctx.pos_y = y + image.height + + +@handler("dlimg") +def draw_downloaded_image(ctx, element: dict) -> None: + """Render an image element. + + Only data URIs and paths that are already on disk can be resolved. Anything + that would need a network fetch or a Home Assistant entity is reported as a + note so the preview stays honest about what it could not show. + """ + x = ctx.coords.x(element["x"]) + y = ctx.coords.y(element["y"]) + x_size = int(element["xsize"]) + y_size = int(element["ysize"]) + rotate = int(element.get("rotate", 0)) + resize_method = element.get("resize_method", "stretch") + url = str(element["url"]) + + source: Image.Image | None = None + + if url.startswith("data:"): + source = _from_data_uri(url) + elif url.startswith(("http://", "https://")): + raise UnsupportedElement( + "remote images are not fetched for the preview; " + "Home Assistant downloads them when drawing to the tag" + ) + elif url.startswith(("image.", "camera.")): + raise UnsupportedElement( + f"entity image '{url}' needs Home Assistant to resolve" + ) + else: + source = _from_path(url) + + if source is None: + _draw_placeholder(ctx, x, y, x_size, y_size, url) + return + + if rotate: + source = source.rotate(-rotate, expand=True) + source = source.convert("RGBA") + source = _resize(source, (x_size, y_size), resize_method) + + layer = Image.new("RGBA", ctx.image.size) + layer.paste(source, (x, y), source) + ctx.image.paste(Image.alpha_composite(ctx.image, layer), (0, 0)) + ctx.pos_y = y + y_size + + +def _resize(source: Image.Image, target: tuple[int, int], method: str) -> Image.Image: + """Resize following the documented `resize_method` values.""" + if source.size == target: + return source + + if method in {"crop", "cover", "contain"}: + source = _resize_preserving_aspect(source, target, method) + if source.size != target: + source = source.resize(target, Image.LANCZOS) + return source + + +def _resize_preserving_aspect( + source: Image.Image, target: tuple[int, int], method: str +) -> Image.Image: + """Scale to cover/contain the target box, then crop or pad.""" + target_w, target_h = target + source_w, source_h = source.size + if not source_w or not source_h: + return source + + scale = ( + max(target_w / source_w, target_h / source_h) + if method in {"crop", "cover"} + else min(target_w / source_w, target_h / source_h) + ) + resized = source.resize( + (max(1, round(source_w * scale)), max(1, round(source_h * scale))), + Image.LANCZOS, + ) + + if method in {"crop", "cover"}: + left = (resized.width - target_w) // 2 + top = (resized.height - target_h) // 2 + return resized.crop((left, top, left + target_w, top + target_h)) + + # contain: centre on a transparent canvas of the target size. + canvas = Image.new("RGBA", target, (0, 0, 0, 0)) + canvas.paste( + resized, + ((target_w - resized.width) // 2, (target_h - resized.height) // 2), + resized, + ) + return canvas + + +def _from_data_uri(url: str) -> Image.Image | None: + try: + header, encoded = url.split(",", 1) + data = base64.b64decode(encoded) if ";base64" in header else urllib.parse.unquote_to_bytes(encoded) + return Image.open(io.BytesIO(data)) + except Exception: # noqa: BLE001 - malformed data URI is a placeholder, not a crash + return None + + +def _from_path(path: str) -> Image.Image | None: + candidate = Path(path) + if not candidate.is_file(): + return None + try: + return Image.open(candidate) + except Exception: # noqa: BLE001 - unreadable image is a placeholder + return None + + +def _draw_placeholder(ctx, x: int, y: int, width: int, height: int, label: str) -> None: + """Draw a boxed label where an image would go.""" + outline = ctx.colors.resolve("black") + ctx.draw.rectangle( + (x, y, x + width, y + height), outline=outline, width=1 + ) + from .fonts import load_font + + size = max(8, min(12, height // 4 or 8)) + font = load_font("ppb.ttf", size) + text = label if len(label) <= 24 else f"{label[:21]}…" + ctx.draw.text((x + 3, y + 3), text, fill=outline, font=font) + ctx.pos_y = y + height diff --git a/backend/app/render/renderer.py b/backend/app/render/renderer.py new file mode 100644 index 0000000..f657c53 --- /dev/null +++ b/backend/app/render/renderer.py @@ -0,0 +1,139 @@ +"""Server-side rendering of a drawcustom payload to a PNG. + +This is the *real* renderer: the same Pillow operations, fonts, colors and +coordinate rules the OpenEPaperLink integration uses when it draws to a tag, so +the preview is pixel-accurate rather than an approximation. + +Written from the documented behaviour of +``custom_components/open_epaper_link/imagegen`` (Apache-2.0); no code is copied. +The layout, colour and coordinate rules were reimplemented against the same +public contract, which is what makes the output line up. + +Only the drawing is implemented here. Data that lives in Home Assistant (entity +history for `plot`, remote images for `dlimg`) is not fetched: a `plot` renders +an empty frame and a `dlimg` renders a labelled placeholder, so the layout is +still visible without the preview depending on a live instance. +""" + +from __future__ import annotations + +import io +from dataclasses import dataclass, field +from typing import Any + +from PIL import Image, ImageDraw + +from .colors import ColorResolver +from .coordinates import Coordinates +from .fonts import assets_available +from .text import HANDLERS, UnsupportedElement + + +class RenderError(RuntimeError): + """Raised when the payload cannot be rendered at all.""" + + +@dataclass +class DrawContext: + """State shared by the element handlers while drawing one image.""" + + image: Image.Image + colors: ColorResolver + coords: Coordinates + # Y position the previous element ended at, used when `y` is omitted. + pos_y: int = 0 + # Non-fatal per-element problems, reported alongside the image. + errors: list[str] = field(default_factory=list) + # Set when the payload asked for something a static preview cannot show. + notes: list[str] = field(default_factory=list) + + @property + def draw(self) -> ImageDraw.ImageDraw: + return ImageDraw.Draw(self.image) + + +def render_payload( + payload: list[dict], + *, + width: int, + height: int, + accent: str = "red", + background: str = "white", + rotate: int = 0, +) -> tuple[bytes, list[str], list[str]]: + """Render a payload to PNG bytes. + + Returns ``(png_bytes, errors, notes)``. Per-element failures are collected + rather than raised, matching how the integration skips a broken element and + keeps drawing the rest. + """ + if width <= 0 or height <= 0: + raise RenderError(f"Invalid canvas size: {width}x{height}") + if not assets_available(): + raise RenderError( + "Font assets are not available. Run backend/scripts/fetch_assets.py " + "or rebuild the Docker image." + ) + + colors = ColorResolver(accent) + + # A 90/270 rotation swaps the canvas, exactly as the integration does. + if rotate in (90, 270): + image = Image.new("RGBA", (height, width), colors.resolve(background)) + else: + image = Image.new("RGBA", (width, height), colors.resolve(background)) + + ctx = DrawContext( + image=image, + colors=colors, + coords=Coordinates(image.width, image.height), + ) + + for index, element in enumerate(payload or []): + if not isinstance(element, dict): + ctx.errors.append(f"Element {index + 1}: expected an object") + continue + if element.get("visible", True) is False: + continue + + element_type = element.get("type") + handler = HANDLERS.get(element_type) + if handler is None: + ctx.errors.append(f"Element {index + 1}: unknown type '{element_type}'") + continue + + try: + handler(ctx, element) + except UnsupportedElement as exc: + ctx.notes.append(f"Element {index + 1}: {exc}") + except KeyError as exc: + ctx.errors.append( + f"Element {index + 1} (type '{element_type}'): missing required key {exc}" + ) + except Exception as exc: # noqa: BLE001 - one bad element must not kill the preview + ctx.errors.append( + f"Element {index + 1} (type '{element_type}'): {type(exc).__name__}: {exc}" + ) + + if rotate: + image = image.rotate(-rotate, expand=True) + + return _to_png(image), ctx.errors, ctx.notes + + +def _to_png(image: Image.Image) -> bytes: + """Encode as a paletted PNG. + + E-paper is 1-bit per channel, so quantising here keeps the preview honest: + what looks right on screen is what the tag can actually show. + """ + buffer = io.BytesIO() + image.convert("RGB").convert("P", palette=Image.ADAPTIVE, colors=8).save( + buffer, format="PNG", optimize=True + ) + return buffer.getvalue() + + +def text_width(text: str, font: Any) -> float: + """Width of a string in pixels.""" + return font.getlength(text) diff --git a/backend/app/render/shapes.py b/backend/app/render/shapes.py new file mode 100644 index 0000000..5257fd4 --- /dev/null +++ b/backend/app/render/shapes.py @@ -0,0 +1,267 @@ +"""Shape handlers for the preview renderer. + +Rectangles, lines, polygons, circles, ellipses and arcs, with the same rounding +and dash behaviour the integration applies. +""" + +from __future__ import annotations + +import math + +from .text import handler + + +def _rounded_corners(spec: str) -> tuple[bool, bool, bool, bool]: + """Parse a `corners` value into (top_left, top_right, bottom_right, bottom_left).""" + if spec == "all": + return True, True, True, True + order = {"top_left": 0, "top_right": 1, "bottom_right": 2, "bottom_left": 3} + flags = [False] * 4 + for part in str(spec).split(","): + index = order.get(part.strip()) + if index is not None: + flags[index] = True + return flags[0], flags[1], flags[2], flags[3] + + +def draw_dashed_line( + draw, + start: tuple[float, float], + end: tuple[float, float], + dash_length: float, + space_length: float, + fill, + width: int = 1, +) -> None: + """Draw a dashed segment by walking the line and alternating dash/space.""" + x1, y1 = start + x2, y2 = end + dx, dy = x2 - x1, y2 - y1 + length = math.hypot(dx, dy) + if length == 0: + return + + step_x, step_y = dx / length, dy / length + position = 0.0 + while position < length: + dash_end = min(position + dash_length, length) + draw.line( + [ + (x1 + step_x * position, y1 + step_y * position), + (x1 + step_x * dash_end, y1 + step_y * dash_end), + ], + fill=fill, + width=width, + ) + position = dash_end + space_length + + +@handler("line") +def draw_line(ctx, element: dict) -> None: + # y is optional: without it the line sits at the previous element's bottom. + if "y_start" not in element: + y_start = ctx.pos_y + int(element.get("y_padding", 0)) + y_end = y_start + else: + y_start = ctx.coords.y(element["y_start"]) + y_end = ctx.coords.y(element.get("y_end", element["y_start"])) + + x_start = ctx.coords.x(element["x_start"]) + x_end = ctx.coords.x(element["x_end"]) + fill = ctx.colors.resolve(element.get("fill", "black")) + width = int(element.get("width", 1)) + + if element.get("dashed", False): + draw_dashed_line( + ctx.draw, + (x_start, y_start), + (x_end, y_end), + float(element.get("dash_length", 5)), + float(element.get("space_length", 3)), + fill, + width, + ) + else: + ctx.draw.line([(x_start, y_start), (x_end, y_end)], fill=fill, width=width) + + ctx.pos_y = max(y_start, y_end) + + +@handler("rectangle") +def draw_rectangle(ctx, element: dict) -> None: + x_start = ctx.coords.x(element["x_start"]) + x_end = ctx.coords.x(element["x_end"]) + y_start = ctx.coords.y(element["y_start"]) + y_end = ctx.coords.y(element["y_end"]) + + fill = ctx.colors.resolve(element.get("fill")) + outline = ctx.colors.resolve(element.get("outline", "black")) + width = int(element.get("width", 1)) + # The integration defaults the radius to 10 as soon as `corners` is present. + default_radius = 10 if "corners" in element else 0 + radius = int(element.get("radius", default_radius)) + corners = _rounded_corners( + element.get("corners", "all" if "radius" in element else "") + ) + + ctx.draw.rounded_rectangle( + (x_start, y_start, x_end, y_end), + fill=fill, + outline=outline, + width=width, + radius=radius, + corners=corners, + ) + ctx.pos_y = y_end + + +@handler("rectangle_pattern") +def draw_rectangle_pattern(ctx, element: dict) -> None: + fill = ctx.colors.resolve(element.get("fill")) + outline = ctx.colors.resolve(element.get("outline", "black")) + width = int(element.get("width", 1)) + default_radius = 10 if "corners" in element else 0 + radius = int(element.get("radius", default_radius)) + corners = _rounded_corners( + element.get("corners", "all" if "radius" in element else "") + ) + + x_start = int(element["x_start"]) + y_start = int(element["y_start"]) + x_size = int(element["x_size"]) + y_size = int(element["y_size"]) + x_offset = int(element["x_offset"]) + y_offset = int(element["y_offset"]) + + max_y = y_start + for column in range(int(element["x_repeat"])): + for row in range(int(element["y_repeat"])): + x_pos = x_start + column * (x_offset + x_size) + y_pos = y_start + row * (y_offset + y_size) + ctx.draw.rounded_rectangle( + (x_pos, y_pos, x_pos + x_size, y_pos + y_size), + fill=fill, + outline=outline, + width=width, + radius=radius, + corners=corners, + ) + max_y = max(max_y, y_pos + y_size) + + ctx.pos_y = max_y + + +@handler("polygon") +def draw_polygon(ctx, element: dict) -> None: + vertices = [ + (ctx.coords.x(x), ctx.coords.y(y)) for x, y in element["points"] + ] + fill = ctx.colors.resolve(element.get("fill")) + outline = ctx.colors.resolve(element.get("outline", "black")) + + # Note: the integration ignores `width` for polygons and always strokes 1px. + ctx.draw.polygon(vertices, fill=fill, outline=outline) + + if vertices: + ctx.pos_y = max(vertex[1] for vertex in vertices) + + +@handler("circle") +def draw_circle(ctx, element: dict) -> None: + x = ctx.coords.x(element["x"]) + y = ctx.coords.y(element["y"]) + radius = int(element["radius"]) + fill = ctx.colors.resolve(element.get("fill")) + outline = ctx.colors.resolve(element.get("outline", "black")) + width = int(element.get("width", 1)) + + ctx.draw.ellipse( + [(x - radius, y - radius), (x + radius, y + radius)], + fill=fill, + outline=outline, + width=width, + ) + ctx.pos_y = y + radius + + +@handler("ellipse") +def draw_ellipse(ctx, element: dict) -> None: + x_start = ctx.coords.x(element["x_start"]) + x_end = ctx.coords.x(element["x_end"]) + y_start = ctx.coords.y(element["y_start"]) + y_end = ctx.coords.y(element["y_end"]) + fill = ctx.colors.resolve(element.get("fill")) + outline = ctx.colors.resolve(element.get("outline", "black")) + width = int(element.get("width", 1)) + + ctx.draw.ellipse( + [(x_start, y_start), (x_end, y_end)], + fill=fill, + outline=outline, + width=width, + ) + ctx.pos_y = y_end + + +@handler("arc") +def draw_arc(ctx, element: dict) -> None: + x = ctx.coords.x(element["x"]) + y = ctx.coords.y(element["y"]) + radius = ctx.coords.size(element["radius"], is_width=True) + start_angle = float(element["start_angle"]) + end_angle = float(element["end_angle"]) + + bbox = [(x - radius, y - radius), (x + radius, y + radius)] + fill = ctx.colors.resolve(element.get("fill")) + outline = ctx.colors.resolve(element.get("outline", "black")) + width = int(element.get("width", 1)) + + if fill: + # A fill turns the arc into a pie slice. + ctx.draw.pieslice(bbox, start=start_angle, end=end_angle, fill=fill, outline=outline) + else: + ctx.draw.arc(bbox, start=start_angle, end=end_angle, fill=outline, width=width) + + ctx.pos_y = y + radius + + +@handler("debug_grid") +def draw_debug_grid(ctx, element: dict) -> None: + width, height = ctx.image.size + spacing = int(element.get("spacing", 20)) + line_color = ctx.colors.resolve(element.get("line_color", "black")) + dashed = element.get("dashed", True) + dash_length = float(element.get("dash_length", 2)) + space_length = float(element.get("space_length", 4)) + show_labels = element.get("show_labels", True) + label_step = int(element.get("label_step", spacing * 2)) + label_color = ctx.colors.resolve(element.get("label_color", "black")) + label_font_size = int(element.get("label_font_size", 12)) + font = load_font_safe(element.get("font", "ppb.ttf"), label_font_size) + + def segment(start, end): + if dashed: + draw_dashed_line( + ctx.draw, start, end, dash_length, space_length, line_color, 1 + ) + else: + ctx.draw.line([start, end], fill=line_color, width=1) + + for y in range(0, height, spacing): + segment((0, y), (width, y)) + if show_labels and label_step and y % label_step == 0: + ctx.draw.text((2, y + 2), str(y), fill=label_color, font=font) + + for x in range(0, width, spacing): + segment((x, 0), (x, height)) + if show_labels and label_step and x % label_step == 0: + ctx.draw.text((x + 2, 2), str(x), fill=label_color, font=font) + + ctx.pos_y = height + + +def load_font_safe(name: str, size: int): + """Load a font for label drawing, tolerating a missing asset.""" + from .fonts import load_font + + return load_font(name, size) diff --git a/backend/app/render/text.py b/backend/app/render/text.py new file mode 100644 index 0000000..7161ef0 --- /dev/null +++ b/backend/app/render/text.py @@ -0,0 +1,301 @@ +"""Element handlers for the preview renderer. + +Each handler draws one element onto the shared canvas. Registration happens via +the `@handler` decorator into `HANDLERS`, which the renderer dispatches on. + +Behaviour follows the integration's `imagegen` package: same defaults, same +coordinate and colour rules, same auto-positioning via `ctx.pos_y`. +""" + +from __future__ import annotations + +import re +from collections.abc import Callable + +from PIL import ImageFont + +from .fonts import load_font + +HANDLERS: dict[str, Callable] = {} + + +class UnsupportedElement(RuntimeError): + """An element that needs live Home Assistant data to draw.""" + + +def handler(element_type: str): + """Register a drawing function for an element type.""" + + def decorate(fn: Callable) -> Callable: + HANDLERS[element_type] = fn + return fn + + return decorate + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# Inline color markup: [red]text[/red]. Mirrors the integration's pattern. +_COLOR_TAG_RE = re.compile( + r"\[(black|white|red|yellow|accent|half_black|half_red|half_yellow|half_accent" + r"|gray|grey|g|hb|hr|hy|ha)\](.*?)\[/\1\]", + re.DOTALL, +) + + +def parse_colored_text(text: str) -> list[tuple[str, str]]: + """Split `[color]text[/color]` markup into (text, color) segments.""" + segments: list[tuple[str, str]] = [] + position = 0 + for match in _COLOR_TAG_RE.finditer(text): + if match.start() > position: + segments.append((text[position:match.start()], "black")) + segments.append((match.group(2), match.group(1))) + position = match.end() + if position < len(text): + segments.append((text[position:], "black")) + return segments + + +def anchor_offset(anchor: str | None, width: float, height: float) -> tuple[float, float]: + """Horizontal and vertical offset implied by a Pillow-style anchor.""" + if not anchor or len(anchor) < 2: + return 0.0, 0.0 + horizontal = {"l": 0.0, "m": -width / 2, "r": -width}.get(anchor[0], 0.0) + vertical = {"a": -height / 2, "t": 0.0, "m": -height / 2, "s": 0.0, "b": -height}.get( + anchor[1], 0.0 + ) + return horizontal, vertical + + +def draw_text_run( + ctx, + x: float, + y: float, + text: str, + font: ImageFont.FreeTypeFont, + color: tuple, + *, + anchor: str | None = None, + align: str = "left", + spacing: int = 5, + stroke_width: int = 0, + stroke_fill: tuple | None = None, +) -> None: + """Draw one run of text at an explicit position.""" + ctx.draw.text( + (x, y), + text, + fill=color, + font=font, + anchor=anchor, + align=align, + spacing=spacing, + stroke_width=stroke_width, + stroke_fill=stroke_fill, + ) + + +# --------------------------------------------------------------------------- +# Text +# --------------------------------------------------------------------------- + +@handler("text") +def draw_text(ctx, element: dict) -> None: + draw = ctx.draw + draw.fontmode = "1" + + x = ctx.coords.x(element["x"]) + if "y" in element: + y = ctx.coords.y(element["y"]) + else: + y = ctx.pos_y + int(element.get("y_padding", 10)) + + size = ctx.coords.size(element.get("size", 20), is_width=False) + font = load_font(element.get("font", "ppb.ttf"), size) + align = element.get("align", "left") + color = ctx.colors.resolve(element.get("color", "black")) + anchor = element.get("anchor") + spacing = int(element.get("spacing", 5)) + stroke_width = int(element.get("stroke_width", 0)) + stroke_fill = ctx.colors.resolve(element.get("stroke_fill", "white")) + + text = str(element["value"]) + max_width = element.get("max_width") + + if max_width is not None: + max_width = ctx.coords.x(max_width) + if element.get("truncate", False): + if draw.textlength(text, font=font) > max_width: + ellipsis = "..." + truncated = text + while truncated and draw.textlength(truncated + ellipsis, font=font) > max_width: + truncated = truncated[:-1] + text = truncated + ellipsis + else: + words = text.split() + lines: list[str] = [] + current: list[str] = [] + for word in words: + candidate = " ".join([*current, word]) + if not current or draw.textlength(candidate, font=font) <= max_width: + current.append(word) + else: + lines.append(" ".join(current)) + current = [word] + if current: + lines.append(" ".join(current)) + text = "\n".join(lines) + + if not anchor: + # The integration defaults to a top anchor for single-line text and a + # baseline-ish one for multi-line. + anchor = "la" if "\n" in text else "lt" + + if element.get("parse_colors", False): + _draw_colored_text( + ctx, x, y, text, font, align, anchor, spacing, stroke_width, stroke_fill + ) + return + + bbox = draw.textbbox((x, y), text, font=font, anchor=anchor, spacing=spacing, align=align) + draw_text_run( + ctx, x, y, text, font, color, + anchor=anchor, align=align, spacing=spacing, + stroke_width=stroke_width, stroke_fill=stroke_fill, + ) + ctx.pos_y = bbox[3] + + +def _draw_colored_text(ctx, x, y, text, font, align, anchor, spacing, stroke_width, stroke_fill): + """Draw text containing `[color]…[/color]` markup.""" + draw = ctx.draw + segments = parse_colored_text(text) + + if "\n" in text: + lines: list[list[tuple[str, str]]] = [[]] + for segment_text, segment_color in segments: + parts = segment_text.split("\n") + for index, part in enumerate(parts): + if part: + lines[-1].append((part, segment_color)) + if index < len(parts) - 1: + lines.append([]) + lines = [line for line in lines if line] + + line_height = font.getbbox("Ay")[3] - font.getbbox("Ay")[1] + block_height = (len(lines) - 1) * (line_height + spacing) + line_height + _, offset_y = anchor_offset(anchor, 0, block_height) + adjusted_y = y + offset_y + + max_y = adjusted_y + for index, line in enumerate(lines): + line_y = adjusted_y + index * (line_height + spacing) + max_y = max( + max_y, + _draw_segment_line( + ctx, line, font, x, line_y, align, anchor, + stroke_width, stroke_fill, + ), + ) + ctx.pos_y = max_y + return + + width = sum(font.getlength(segment_text) for segment_text, _ in segments) + offset_x, _ = anchor_offset(anchor, width, 0) + if align == "center": + offset_x -= width / 2 + elif align == "right": + offset_x -= width + + max_y = y + cursor = x + offset_x + for segment_text, segment_color in segments: + color = ctx.colors.resolve(segment_color) + bbox = draw.textbbox((cursor, y), segment_text, font=font, anchor="lt") + draw_text_run( + ctx, cursor, y, segment_text, font, color, + anchor="lt", stroke_width=stroke_width, stroke_fill=stroke_fill, + ) + max_y = max(max_y, bbox[3]) + cursor += font.getlength(segment_text) + ctx.pos_y = max_y + + +def _draw_segment_line(ctx, line, font, x, line_y, align, anchor, stroke_width, stroke_fill): + """Draw one line of colour-markup text. Returns its bottom edge.""" + width = sum(font.getlength(segment_text) for segment_text, _ in line) + offset_x, _ = anchor_offset(anchor, width, 0) + if align == "center": + offset_x -= width / 2 + elif align == "right": + offset_x -= width + + cursor = x + offset_x + bottom = line_y + for segment_text, segment_color in line: + color = ctx.colors.resolve(segment_color) + bbox = ctx.draw.textbbox((cursor, line_y), segment_text, font=font, anchor="lt") + draw_text_run( + ctx, cursor, line_y, segment_text, font, color, + anchor="lt", stroke_width=stroke_width, stroke_fill=stroke_fill, + ) + bottom = max(bottom, bbox[3]) + cursor += font.getlength(segment_text) + return bottom + + +@handler("multiline") +def draw_multiline(ctx, element: dict) -> None: + """Draw delimiter-separated lines, each offset by `offset_y`.""" + draw = ctx.draw + draw.fontmode = "1" + + size = ctx.coords.size(element.get("size", 20), is_width=False) + font = load_font(element.get("font", "ppb.ttf"), size) + color = ctx.colors.resolve(element.get("color", "black")) + align = element.get("align", "left") + anchor = element.get("anchor", "lm") + stroke_width = int(element.get("stroke_width", 0)) + stroke_fill = ctx.colors.resolve(element.get("stroke_fill", "white")) + + x = ctx.coords.x(element["x"]) + if "y" in element: + current_y = ctx.coords.y(element["y"]) + elif "start_y" in element: + current_y = ctx.coords.y(element["start_y"]) + else: + current_y = ctx.pos_y + int(element.get("y_padding", 10)) + + # Newlines are dropped: the delimiter is the only line break. + lines = str(element["value"]).replace("\n", "").split(str(element["delimiter"])) + offset_y = int(element["offset_y"]) + + max_y = current_y + for line in lines: + if element.get("parse_colors", False): + for segment_text, segment_color in parse_colored_text(str(line)): + segment_color_resolved = ctx.colors.resolve(segment_color) + bbox = draw.textbbox( + (x, current_y), segment_text, font=font, anchor="lt" + ) + draw_text_run( + ctx, x, current_y, segment_text, font, segment_color_resolved, + anchor="lt", stroke_width=stroke_width, stroke_fill=stroke_fill, + ) + max_y = max(max_y, bbox[3]) + else: + bbox = draw.textbbox( + (x, current_y), str(line), font=font, anchor=anchor, align=align + ) + draw_text_run( + ctx, x, current_y, str(line), font, color, + anchor=anchor, align=align, + stroke_width=stroke_width, stroke_fill=stroke_fill, + ) + max_y = max(max_y, bbox[3]) + current_y += offset_y + + ctx.pos_y = max_y diff --git a/backend/app/render/visualizations.py b/backend/app/render/visualizations.py new file mode 100644 index 0000000..6aea548 --- /dev/null +++ b/backend/app/render/visualizations.py @@ -0,0 +1,101 @@ +"""Visualisation handlers for the preview renderer. + +The progress bar is drawn in full. The plot needs entity history from Home +Assistant, which a static preview cannot obtain, so it draws its frame, axes and +legend so the reserved area and styling are visible, and reports a note. +""" + +from __future__ import annotations + +from .fonts import load_font +from .text import handler + + +@handler("progress_bar") +def draw_progress_bar(ctx, element: dict) -> None: + x_start = ctx.coords.x(element["x_start"]) + y_start = ctx.coords.y(element["y_start"]) + x_end = ctx.coords.x(element["x_end"]) + y_end = ctx.coords.y(element["y_end"]) + + progress = min(100, max(0, int(element["progress"]))) + direction = element.get("direction", "right") + background = ctx.colors.resolve(element.get("background", "white")) + fill = ctx.colors.resolve(element.get("fill", "red")) + outline = ctx.colors.resolve(element.get("outline", "black")) + width = int(element.get("width", 1)) + show_percentage = element.get("show_percentage", False) + # Note: the integration reads `font_name` here, not `font`. + font_name = element.get("font_name", element.get("font", "ppb.ttf")) + + ctx.draw.rectangle( + ((x_start, y_start), (x_end, y_end)), + fill=background, + outline=outline, + width=width, + ) + + if direction in {"right", "left"}: + progress_width = int((x_end - x_start) * (progress / 100)) + progress_height = y_end - y_start + else: + progress_width = x_end - x_start + progress_height = int((y_end - y_start) * (progress / 100)) + + if direction == "right": + ctx.draw.rectangle((x_start, y_start, x_start + progress_width, y_end), fill=fill) + elif direction == "left": + ctx.draw.rectangle((x_end - progress_width, y_start, x_end, y_end), fill=fill) + elif direction == "up": + ctx.draw.rectangle((x_start, y_end - progress_height, x_end, y_end), fill=fill) + elif direction == "down": + ctx.draw.rectangle((x_start, y_start, x_end, y_start + progress_height), fill=fill) + + ctx.draw.rectangle((x_start, y_start, x_end, y_end), fill=None, outline=outline, width=width) + + if show_percentage: + font_size = min(y_end - y_start - 4, x_end - x_start - 4, 20) + font = load_font(font_name, max(6, font_size)) + text = f"{progress}%" + bbox = ctx.draw.textbbox((0, 0), text, font=font) + text_x = (x_start + x_end - (bbox[2] - bbox[0])) / 2 + text_y = (y_start + y_end - (bbox[3] - bbox[1])) / 2 + # Flip the label color so it stays readable over the filled part. + text_color = background if progress > 50 else fill + ctx.draw.text((text_x, text_y), text, font=font, fill=text_color, anchor="lt") + + ctx.pos_y = y_end + + +@handler("plot") +def draw_plot(ctx, element: dict) -> None: + """Draw the plot frame and axes. + + Entity history is only available inside Home Assistant, so the data series + cannot be drawn. Rendering the frame keeps the layout accurate. + """ + x_start = ctx.coords.x(element.get("x_start", 0)) + y_start = ctx.coords.y(element.get("y_start", 0)) + x_end = ctx.coords.x(element.get("x_end", ctx.image.width - 1 - x_start)) + y_end = ctx.coords.y(element.get("y_end", ctx.image.height - 1 - y_start)) + + font = load_font(element.get("font", "ppb.ttf"), int(element.get("size", 10))) + + # The integration outlines the full area in black when `debug` is set. + if element.get("debug", False): + ctx.draw.rectangle((x_start, y_start, x_end, y_end), outline=ctx.colors.resolve("black")) + ctx.draw.rectangle((x_start, y_start, x_end, y_end), outline=ctx.colors.resolve("red")) + + # A light frame plus an axis line, so an empty plot still reads as a plot. + grid_color = ctx.colors.resolve("half_black") + ctx.draw.rectangle((x_start, y_start, x_end, y_end), outline=grid_color, width=1) + ctx.draw.line([(x_start, y_end), (x_end, y_end)], fill=ctx.colors.resolve("black"), width=1) + + series = element.get("data") or [] + label = f"plot · {len(series)} series" + ctx.draw.text((x_start + 4, y_start + 4), label, fill=grid_color, font=font) + + ctx.pos_y = y_end + ctx.notes.append( + "plot needs Home Assistant history; the frame is drawn but the data is not" + ) diff --git a/backend/app/schema.py b/backend/app/schema.py index 62c42db..bf09893 100644 --- a/backend/app/schema.py +++ b/backend/app/schema.py @@ -16,6 +16,7 @@ points list of [x, y] pairs iconlist list of mdi icon names plotdata list of {entity, color, width, ...} objects +object nested option object (plot axes / legends), `None` = off """ from __future__ import annotations @@ -56,6 +57,20 @@ POSITION_GROUP = "Position" STYLE_GROUP = "Style" +AXES_GROUP = "Axes & legends" + +# `corners` accepts "all" or a comma separated list of corners. +CORNERS = [ + "all", + "top_left,top_right", + "bottom_left,bottom_right", + "top_left,bottom_left", + "top_right,bottom_right", + "top_left", + "top_right", + "bottom_left", + "bottom_right", +] def _num(key: str, label: str, default: Any = None, *, group: str = POSITION_GROUP, @@ -104,6 +119,20 @@ def _bool(key: str, label: str, default: bool = False, *, return {"key": key, "label": label, "kind": "bool", "default": default, "group": group} +def _object(key: str, label: str, emit_key: str, fields: list[dict], *, + group: str = AXES_GROUP) -> dict: + """A nested option object such as a plot axis. + + The value is `None` (switched off, omitted) or a dict of sub-properties. + Home Assistant treats an empty dict as off too, so `emit_key` is emitted + when every sub-property is at its default. + """ + return { + "key": key, "label": label, "kind": "object", "default": None, + "group": group, "fields": fields, "emit_key": emit_key, + } + + # --------------------------------------------------------------------------- # Element types # --------------------------------------------------------------------------- @@ -129,6 +158,8 @@ def _bool(key: str, label: str, default: bool = False, *, _num("stroke_width", "Stroke width", 0, group="Advanced", min=0), _color("stroke_fill", "Stroke color", "white", group="Advanced"), _num("y_padding", "Y padding", 10, group="Advanced"), + _select("align", "Align lines", ["left", "center", "right"], "left", + group="Advanced"), _bool("parse_colors", "Parse [color] markup"), _bool("truncate", "Truncate with ellipsis"), VISIBLE, @@ -151,6 +182,8 @@ def _bool(key: str, label: str, default: bool = False, *, _num("size", "Font size", 20, group=STYLE_GROUP, min=1), _select("font", "Font", FONTS, "ppb.ttf"), _color("color", "Color", "black"), + # HA anchors every line at "lm" (left-middle) unless told otherwise. + _select("anchor", "Anchor", ANCHORS, "lm"), _num("spacing", "Extra spacing", 0, group="Advanced"), _bool("parse_colors", "Parse [color] markup"), VISIBLE, @@ -268,8 +301,7 @@ def _bool(key: str, label: str, default: bool = False, *, _color("outline", "Outline", "black"), _num("width", "Border width", 1, group=STYLE_GROUP, min=0), _num("radius", "Corner radius", 0, group=STYLE_GROUP, min=0), - _select("corners", "Rounded corners", - ["all", "top_left", "top_right", "bottom_left", "bottom_right"], "all"), + _select("corners", "Rounded corners", CORNERS, "all"), VISIBLE, ], }, @@ -294,6 +326,8 @@ def _bool(key: str, label: str, default: bool = False, *, _color("fill", "Fill", None, allow_none=True), _color("outline", "Outline", "black"), _num("width", "Border width", 1, group=STYLE_GROUP, min=0), + _num("radius", "Corner radius", 0, group=STYLE_GROUP, min=0), + _select("corners", "Rounded corners", CORNERS, "all"), VISIBLE, ], }, @@ -404,13 +438,51 @@ def _bool(key: str, label: str, default: bool = False, *, _num("y_start", "Y start", 20), _num("x_end", "X end", 199), _num("y_end", "Y end", 119), - _num("duration", "Duration (s)", 36000, group="Content", min=60), + _num("duration", "Duration (s)", 86400, group="Content", min=60), _num("low", "Min value", None, group="Content"), _num("high", "Max value", None, group="Content"), _select("font", "Font", FONTS, "ppb.ttf"), _num("size", "Font size", 10, group=STYLE_GROUP, min=1), _bool("round_values", "Round values"), _bool("debug", "Debug borders"), + _object("ylegend", "Y legend", "position", [ + _num("width", "Width (-1 = auto)", -1, group=AXES_GROUP), + _color("color", "Color", "black", group=AXES_GROUP), + _select("position", "Position", ["left", "right"], "left", group=AXES_GROUP), + _num("size", "Font size", 10, group=AXES_GROUP, min=1), + ]), + _object("yaxis", "Y axis", "width", [ + _num("width", "Line width", 1, group=AXES_GROUP, min=0), + _color("color", "Color", "black", group=AXES_GROUP), + _num("tick_length", "Tick length", 4, group=AXES_GROUP, min=0), + _num("tick_width", "Tick width", 2, group=AXES_GROUP, min=0), + _num("tick_every", "Tick every", 1, group=AXES_GROUP, step=0.1), + _bool("grid", "Grid", True, group=AXES_GROUP), + _color("grid_color", "Grid color", "black", group=AXES_GROUP), + _select("grid_style", "Grid style", ["dotted", "dashed", "lines"], "dotted", + group=AXES_GROUP), + ]), + _object("xlegend", "X legend (time labels)", "position", [ + _text("format", "Time format", "%H:%M", group=AXES_GROUP, template=False, + help="Python strftime format, e.g. %H:%M"), + _num("interval", "Interval (s)", None, group=AXES_GROUP, min=1, + help="Empty = a quarter of the duration"), + _bool("snap_to_hours", "Snap to hours", True, group=AXES_GROUP), + _num("size", "Font size", 10, group=AXES_GROUP, min=1), + _select("position", "Position", ["bottom", "top"], "bottom", group=AXES_GROUP), + _color("color", "Color", "black", group=AXES_GROUP), + _num("height", "Height (-1 = auto, 0 = hidden)", -1, group=AXES_GROUP), + ]), + _object("xaxis", "X axis", "width", [ + _num("width", "Line width", 1, group=AXES_GROUP, min=0), + _color("color", "Color", "black", group=AXES_GROUP), + _num("tick_length", "Tick length", 4, group=AXES_GROUP, min=0), + _num("tick_width", "Tick width", 2, group=AXES_GROUP, min=0), + _bool("grid", "Grid", True, group=AXES_GROUP), + _color("grid_color", "Grid color", "black", group=AXES_GROUP), + _select("grid_style", "Grid style", ["dotted", "dashed", "lines"], "dotted", + group=AXES_GROUP), + ]), VISIBLE, ], }, @@ -461,13 +533,26 @@ def _bool(key: str, label: str, default: bool = False, *, "debug_grid": set(), } +# Optional fields whose Home Assistant default is *dynamic* (auto-positioned, +# canvas size, derived from another field …). No schema default can match it, +# so "omit when equal to the default" would silently move the element on the +# display. These are always emitted, exactly like required fields. +ALWAYS_EMIT_FIELDS: dict[str, set[str]] = { + "multiline": {"y"}, # HA: last position + y_padding + "line": {"y_start", "y_end"}, # HA: auto / = y_start + "plot": {"x_start", "y_start", "x_end", "y_end"}, # HA: 0 / canvas size + "debug_grid": {"label_step"}, # HA: 2 * spacing +} + + +def emitted_always(element_type: str) -> set[str]: + """Fields the generator emits regardless of the schema default.""" + return REQUIRED_FIELDS.get(element_type, set()) | ALWAYS_EMIT_FIELDS.get(element_type, set()) + + # Fields that are purely editor-side and never emitted into the payload. INTERNAL_FIELDS = {"visible"} -# Fields whose value is a Home Assistant template and must be emitted verbatim -# (unquoted) so Jinja can evaluate it. -TEMPLATE_FIELDS = {"value", "data", "url", "progress", "low", "high", "duration"} - DISPLAY_PRESETS = [ {"label": "2.9\" BWR (296x128)", "width": 296, "height": 128}, {"label": "2.9\" BWR (128x296)", "width": 128, "height": 296}, @@ -499,4 +584,4 @@ def public_schema() -> dict: "anchors": ANCHORS, "fonts": FONTS, "displayPresets": DISPLAY_PRESETS, - } \ No newline at end of file + } diff --git a/backend/app/storage.py b/backend/app/storage.py index 49ebf8c..edbe90c 100644 --- a/backend/app/storage.py +++ b/backend/app/storage.py @@ -8,13 +8,20 @@ import time import uuid from pathlib import Path -from typing import Any DATA_DIR = Path(os.environ.get("DATA_DIR", "./data")) PROJECTS_DIR = DATA_DIR / "projects" SETTINGS_FILE = DATA_DIR / "settings.json" _SLUG_RE = re.compile(r"[^a-zA-Z0-9_-]+") +# Project ids map 1:1 to file names, so only a safe, non-empty subset is valid. +# Anything else is rejected instead of silently rewritten, which used to map +# `!!!` to `.json` and `a.b` / `ab` to the same file. +_ID_RE = re.compile(r"[A-Za-z0-9_-]{1,64}") + + +class InvalidProjectId(ValueError): + pass def _ensure_dirs() -> None: @@ -26,9 +33,26 @@ def _slugify(name: str) -> str: return slug or "project" +def is_valid_id(project_id: str) -> bool: + return bool(_ID_RE.fullmatch(project_id or "")) + + def _project_path(project_id: str) -> Path: - safe = _SLUG_RE.sub("", project_id) - return PROJECTS_DIR / f"{safe}.json" + if not is_valid_id(project_id): + raise InvalidProjectId(project_id) + return PROJECTS_DIR / f"{project_id}.json" + + +def _atomic_write(path: Path, text: str, mode: int | None = None) -> None: + """Write via a temp file + rename so a crash never leaves half a JSON file.""" + tmp = path.with_name(f".{path.name}.{uuid.uuid4().hex[:8]}.tmp") + try: + tmp.write_text(text, "utf-8") + if mode is not None: + os.chmod(tmp, mode) + os.replace(tmp, path) + finally: + tmp.unlink(missing_ok=True) def list_projects() -> list[dict]: @@ -52,7 +76,13 @@ def list_projects() -> list[dict]: return projects +def project_exists(project_id: str) -> bool: + return is_valid_id(project_id) and _project_path(project_id).exists() + + def get_project(project_id: str) -> dict | None: + if not is_valid_id(project_id): + return None path = _project_path(project_id) if not path.exists(): return None @@ -65,7 +95,7 @@ def get_project(project_id: str) -> dict | None: def save_project(project: dict) -> dict: _ensure_dirs() project = dict(project) - if not project.get("id"): + if not is_valid_id(project.get("id") or ""): project["id"] = _slugify(project.get("name", "")) + "-" + uuid.uuid4().hex[:6] project.setdefault("name", "Untitled") project.setdefault("width", 296) @@ -73,13 +103,16 @@ def save_project(project: dict) -> dict: project.setdefault("nodes", []) project.setdefault("variables", []) project["updatedAt"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - _project_path(project["id"]).write_text( - json.dumps(project, indent=2, ensure_ascii=False), "utf-8" + _atomic_write( + _project_path(project["id"]), + json.dumps(project, indent=2, ensure_ascii=False), ) return project def delete_project(project_id: str) -> bool: + if not is_valid_id(project_id): + return False path = _project_path(project_id) if path.exists(): path.unlink() @@ -97,35 +130,66 @@ def load_settings() -> dict: return {} +def _norm_url(url: str | None) -> str: + return (url or "").strip().rstrip("/").lower() + + def save_settings(settings: dict) -> dict: + """Merge and persist settings. + + The stored token is bound to the Home Assistant URL it was entered for. + Without that, anyone who can reach the editor could point `haUrl` at their + own server and have the next status check send them the token. So changing + the URL without supplying a token in the same request drops the stored one. + Returns the saved settings plus `tokenCleared` when that happened. + """ _ensure_dirs() current = load_settings() + token_cleared = False + if "haUrl" in settings and "haToken" not in settings: + before = current.get("haUrl") or os.environ.get("HA_URL", "") + after = settings["haUrl"] or os.environ.get("HA_URL", "") + if _norm_url(before) != _norm_url(after): + token_cleared = bool(current.pop("haToken", None)) or bool(os.environ.get("HA_TOKEN")) current.update(settings) - SETTINGS_FILE.write_text( - json.dumps(current, indent=2, ensure_ascii=False), "utf-8" + _atomic_write( + SETTINGS_FILE, + json.dumps(current, indent=2, ensure_ascii=False), + mode=0o600, # holds a long-lived Home Assistant token ) - return current + return {**current, "tokenCleared": token_cleared} def effective_settings() -> dict: - """Settings with environment variables as fallback.""" + """Settings with environment variables as fallback. + + The `HA_TOKEN` environment token is only ever sent to `HA_URL`: if the URL + was changed in the UI, a token has to be entered there as well. + """ settings = load_settings() + env_url = os.environ.get("HA_URL", "") + url = settings.get("haUrl") or env_url + token = settings.get("haToken") or "" + source = "settings" if token else "none" + if not token and os.environ.get("HA_TOKEN") and _norm_url(url) == _norm_url(env_url): + token = os.environ["HA_TOKEN"] + source = "env" return { - "haUrl": settings.get("haUrl") or os.environ.get("HA_URL", ""), - "haToken": settings.get("haToken") or os.environ.get("HA_TOKEN", ""), + "haUrl": url, + "haToken": token, + "tokenSource": source, "deviceId": settings.get("deviceId", ""), - "service": settings.get("service", "open_epaper_link.drawcustom"), + "service": settings.get("service") or "open_epaper_link.drawcustom", } def public_settings() -> dict: """Settings safe to send to the browser (token redacted).""" settings = effective_settings() - token = settings.get("haToken") or "" return { - "haUrl": settings.get("haUrl", ""), - "deviceId": settings.get("deviceId", ""), - "service": settings.get("service", ""), - "hasToken": bool(token), - "tokenSource": "env" if os.environ.get("HA_TOKEN") and not load_settings().get("haToken") else "settings", - } \ No newline at end of file + "haUrl": settings["haUrl"], + "deviceId": settings["deviceId"], + "service": settings["service"], + "hasToken": bool(settings["haToken"]), + "tokenSource": settings["tokenSource"], + } diff --git a/backend/app/templating.py b/backend/app/templating.py new file mode 100644 index 0000000..408f6ff --- /dev/null +++ b/backend/app/templating.py @@ -0,0 +1,430 @@ +"""Render and validate generated drawcustom templates. + +The editor emits a Home Assistant Jinja template. Home Assistant is the only +thing that can evaluate it against live entity state, so there are two render +paths: + +* **Home Assistant** (``POST /api/template``) -- authoritative. Used by + "Send to display" and by validation whenever Home Assistant is reachable. +* **Local sandbox** -- a Jinja environment with permissive stubs, used when + Home Assistant is unconfigured or down. It cannot know entity states, so + anything it cannot resolve renders as ``0``. The point is only to prove the + template *renders* and yields a payload the integration will accept. + +Validation runs in stages, and the failing stage is reported so the UI can +point at the right problem: + +1. ``render`` -- Jinja failed to render the template. +2. ``parse`` -- the rendered text is not valid JSON. +3. ``elements`` -- the JSON is not a list of drawable elements. + +Why this module exists: ``imagegen/core.py`` reads ``payload`` as a finished +list of elements and never renders Jinja itself, and the REST +``/api/services/...`` endpoint does not render templates passed in the request +body either. Sending the raw template therefore reaches the tag unrendered and +fails, so the push path renders first and sends the resulting list. +""" + +from __future__ import annotations + +import json +from typing import Any + +from jinja2 import TemplateError +from jinja2.runtime import Undefined +from jinja2.sandbox import SandboxedEnvironment + +from .generator import generate_template +from .schema import ELEMENT_TYPES_BY_NAME + +# --------------------------------------------------------------------------- +# Required keys +# --------------------------------------------------------------------------- + +# Mirrors the ``requires=`` decorators in +# custom_components/open_epaper_link/imagegen/*.py. The integration builds these +# into ``ElementType`` handlers and raises KeyError when one is missing, which +# surfaces as a per-element error and silently drops that element on the tag. +HANDLER_REQUIRES: dict[str, tuple[str, ...]] = { + "text": ("x", "value"), + "multiline": ("x", "value", "delimiter", "offset_y"), + "line": ("x_start", "x_end"), + "rectangle": ("x_start", "x_end", "y_start", "y_end"), + "rectangle_pattern": ( + "x_start", "x_size", "y_start", "y_size", + "x_repeat", "y_repeat", "x_offset", "y_offset", + ), + "polygon": ("points",), + "circle": ("x", "y", "radius"), + "ellipse": ("x_start", "x_end", "y_start", "y_end"), + "arc": ("x", "y", "radius", "start_angle", "end_angle"), + "icon": ("x", "y", "value", "size"), + "icon_sequence": ("x", "y", "icons", "size"), + "qrcode": ("x", "y", "data"), + "dlimg": ("x", "y", "url", "xsize", "ysize"), + "plot": ("data",), + "progress_bar": ("x_start", "x_end", "y_start", "y_end", "progress"), + "debug_grid": (), +} + +# Keys that must be lists; passing a scalar would raise inside the handler. +_LIST_KEYS = {"points", "icons", "data"} + +_MAX_ISSUES = 20 +_EXCERPT_RADIUS = 60 + + +# --------------------------------------------------------------------------- +# Permissive stubs for offline rendering +# --------------------------------------------------------------------------- + +class PermissiveUndefined(Undefined): + """An undefined value that behaves like ``0`` and never raises. + + Home Assistant owns the real values. For a static check we only need the + template to render, so an unresolved name evaluates to zero and any + attribute, item or call on it yields another permissive undefined. + Rendering as ``""`` (Jinja's default) would produce invalid JSON in numeric + slots such as ``"x": {{ offset }},``. + """ + + __slots__ = () + + def __str__(self) -> str: + return "0" + + def __repr__(self) -> str: + return "0" + + def __int__(self) -> int: + return 0 + + def __float__(self) -> float: + return 0.0 + + def __index__(self) -> int: + return 0 + + def __round__(self, ndigits: int | None = None) -> int: + return 0 + + def __bool__(self) -> bool: + return False + + def __len__(self) -> int: + return 0 + + def __iter__(self): + return iter(()) + + def __getattr__(self, name: str) -> Any: + if name.startswith("_"): + raise AttributeError(name) + return self + + def __getitem__(self, key: Any) -> Any: + return self + + def __call__(self, *args: Any, **kwargs: Any) -> Any: + return self + + def _arithmetic(self, other: Any = None) -> Any: + return self + + __add__ = __radd__ = __sub__ = __rsub__ = _arithmetic + __mul__ = __rmul__ = __truediv__ = __rtruediv__ = _arithmetic + __floordiv__ = __rfloordiv__ = __mod__ = __rmod__ = _arithmetic + __pow__ = __rpow__ = __lshift__ = __rlshift__ = _arithmetic + + def __neg__(self) -> Any: + return self + + def __pos__(self) -> Any: + return self + + def __abs__(self) -> Any: + return self + + +def _stub(*args: Any, **kwargs: Any) -> PermissiveUndefined: + """A stand-in for any Home Assistant template function.""" + return PermissiveUndefined() + + +def _stub_filter(value: Any, *args: Any, **kwargs: Any) -> PermissiveUndefined: + """A stand-in for any Home Assistant/Jinja filter we do not know.""" + return PermissiveUndefined() + + +# Template functions Home Assistant exposes. Anything not listed here still +# resolves -- unknown *names* become PermissiveUndefined -- but registering the +# common ones keeps `.get()`-style chains readable and self-documenting. +_HA_GLOBALS = ( + "states", "state_attr", "is_state", "is_state_attr", "has_value", + "expand", "device_entities", "device_id", "device_name", + "area_entities", "area_id", "area_name", "labels", + "now", "utcnow", "today_at", "as_timestamp", "as_datetime", "as_local", + "relative_time", "timedelta", "strptime", "time_trigger", + "float", "int", "floor", "ceil", "clamp", "iif", "distance", "closest", + "this", "trigger", "wait", "apply", "pack", "unpack", +) + + +class _FallbackDict(dict): + """A mapping that answers any key with a fallback. + + Jinja resolves filters and tests at compile time via ``.get(name)`` and + fails the template when one is missing. Home Assistant provides filters the + sandbox does not know, so unknown names resolve to a permissive stub + instead of being a syntax error. + """ + + def __init__(self, fallback: Any, initial: dict | None = None) -> None: + super().__init__(initial or {}) + self._fallback = fallback + + def __missing__(self, key: Any) -> Any: + return self._fallback + + def get(self, key: Any, default: Any = None) -> Any: + if key in self: + return super().get(key, default) + return self._fallback + + +def build_local_env() -> SandboxedEnvironment: + """A sandboxed Jinja environment that renders anything.""" + env = SandboxedEnvironment( + undefined=PermissiveUndefined, + autoescape=False, + keep_trailing_newline=False, + ) + env.filters = _FallbackDict(_stub_filter, env.filters) + env.tests = _FallbackDict(lambda *a, **k: False, env.tests) + env.globals.update(dict.fromkeys(_HA_GLOBALS, _stub)) + return env + + +# --------------------------------------------------------------------------- +# Rendering +# --------------------------------------------------------------------------- + +class RenderFailed(RuntimeError): + """Raised when a template cannot be rendered at all.""" + + +def render_locally(template: str) -> str: + """Render with the local sandbox. Raises RenderFailed on a template error.""" + env = build_local_env() + try: + return env.from_string(template).render() + except TemplateError as exc: + raise RenderFailed(_describe_template_error(exc)) from exc + except Exception as exc: + # Runtime failures inside an expression (division by zero, a bad + # attribute on a real value) are not TemplateError but still mean the + # template cannot render. + raise RenderFailed(f"{type(exc).__name__}: {exc}") from exc + + +async def render_with_ha(client: Any, template: str) -> str: + """Render with Home Assistant. Raises the client's error on failure.""" + return await client.render_template(template) + + +def _describe_template_error(exc: TemplateError) -> str: + """Add line information to a Jinja error when it carries any.""" + message = str(exc).split("\n", 1)[0].strip() + lineno = getattr(exc, "lineno", None) + if lineno: + return f"{message} (line {lineno})" + return message + + +# --------------------------------------------------------------------------- +# Parsing and validation +# --------------------------------------------------------------------------- + +def _excerpt(text: str, position: int, radius: int = _EXCERPT_RADIUS) -> str: + """A snippet around ``position`` with a caret under the offending column. + + The caret is placed on the line that actually contains the error, which is + not necessarily the last line of the snippet. + """ + position = max(0, min(position, len(text))) + start = max(0, position - radius) + end = min(len(text), position + radius) + + prefix = "…" if start > 0 else "" + suffix = "…" if end < len(text) else "" + body = prefix + text[start:end] + suffix + + # Offset of the error within the snippet. + offset = len(prefix) + (position - start) + + # The snippet line containing the error, and the column within it. + line_start = body.rfind("\n", 0, offset) + 1 + line_end = body.find("\n", offset) + if line_end == -1: + line_end = len(body) + column = offset - line_start + + caret = " " * max(0, column) + "^" + return body[: line_end] + "\n" + caret + body[line_end:] + + +def _check_element(index: int, element: Any, issues: list[str]) -> None: + number = index + 1 + if not isinstance(element, dict): + issues.append(f"Element {number}: expected an object, got {type(element).__name__}") + return + + element_type = element.get("type") + if not element_type: + issues.append(f"Element {number}: missing required key 'type'") + return + if element_type not in ELEMENT_TYPES_BY_NAME: + issues.append(f"Element {number}: unknown type '{element_type}'") + return + + for key in HANDLER_REQUIRES.get(element_type, ()): + if key not in element: + issues.append( + f"Element {number} (type '{element_type}'): missing required key '{key}'" + ) + + for key in _LIST_KEYS & element.keys(): + if not isinstance(element[key], list): + issues.append( + f"Element {number} (type '{element_type}'): '{key}' must be a list, " + f"got {type(element[key]).__name__}" + ) + + +def parse_rendered(rendered: str) -> dict: + """Parse rendered output into a payload and report any problem. + + Returns ``{ok, stage, error, excerpt, issues, payload}``. ``stage`` is the + stage that failed, or ``"ok"`` when everything passed. + """ + text = (rendered or "").strip() + + if not text: + return { + "ok": False, + "stage": "render", + "error": "The template rendered to an empty string.", + "excerpt": None, + "issues": [], + "payload": None, + } + + try: + payload = json.loads(text) + except json.JSONDecodeError as exc: + return { + "ok": False, + "stage": "parse", + "error": f"Rendered output is not valid JSON: {exc.msg} (line {exc.lineno}, column {exc.colno})", + "excerpt": _excerpt(text, exc.pos), + "issues": [], + "payload": None, + } + + if not isinstance(payload, list): + return { + "ok": False, + "stage": "elements", + "error": f"Expected a JSON array of elements, got {type(payload).__name__}.", + "excerpt": _excerpt(text, 0), + "issues": [], + "payload": None, + } + + issues: list[str] = [] + for index, element in enumerate(payload): + _check_element(index, element, issues) + + if len(issues) > _MAX_ISSUES: + # Truncate exactly: a single element can add several issues at once, so + # stopping mid-collection would let the list overshoot the cap. + issues = issues[:_MAX_ISSUES] + ["… further problems omitted"] + + if issues: + return { + "ok": False, + "stage": "elements", + "error": issues[0], + "excerpt": None, + "issues": issues, + "payload": payload, + } + + return { + "ok": True, + "stage": "ok", + "error": None, + "excerpt": None, + "issues": [], + "payload": payload, + } + + +# --------------------------------------------------------------------------- +# High level entry points +# --------------------------------------------------------------------------- + +async def validate_project(project: dict, client: Any = None) -> dict: + """Generate, render and validate a project's template. + + ``client`` is a HomeAssistantClient, or None to render locally. Falls back + to the local sandbox if Home Assistant cannot be reached, so the editor + still reports template and JSON problems while offline. ``source`` says + which path actually produced the output. + """ + template = generate_template(project) + + source = "local" + rendered: str | None = None + ha_error: str | None = None + + if client is not None: + try: + rendered = await client.render_template(template) + source = "ha" + except Exception as exc: # noqa: BLE001 - see below + # Deliberately broad. The fallback exists so the editor still + # reports template and JSON problems while Home Assistant is + # unreachable, misconfigured, or returning its own errors, and + # those surface as several unrelated exception types. + ha_error = str(exc) + + if rendered is None: + try: + rendered = render_locally(template) + except RenderFailed as exc: + return { + "ok": False, + "source": source, + "stage": "render", + "error": str(exc), + "excerpt": _excerpt(template, 0), + "issues": [], + "template": template, + } + + result = parse_rendered(rendered) + result["source"] = source + result["template"] = template + if result["payload"] is not None: + result["count"] = len(result["payload"]) + result["rendered"] = rendered if not result["ok"] else None + if ha_error: + result["haError"] = ha_error + return result + + +def payload_from_rendered(rendered: str) -> tuple[list | None, dict]: + """Convenience wrapper for the push path.""" + result = parse_rendered(rendered) + return result["payload"] if result["ok"] else None, result diff --git a/backend/app/test_api.py b/backend/app/test_api.py new file mode 100644 index 0000000..61a4265 --- /dev/null +++ b/backend/app/test_api.py @@ -0,0 +1,374 @@ +"""HTTP-level regression tests. + +Run with: python -m app.test_api (from the backend directory) +""" + +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from . import main, storage +from .render import assets_available + +ENV_KEYS = ("HA_URL", "HA_TOKEN") + + +def _fresh() -> TestClient: + """A client with an empty data dir and no HA environment.""" + data = Path(tempfile.mkdtemp()) + storage.DATA_DIR = data + storage.PROJECTS_DIR = data / "projects" + storage.SETTINGS_FILE = data / "settings.json" + for key in ENV_KEYS: + os.environ.pop(key, None) + return TestClient(main.app) + + +# --------------------------------------------------------------------------- +# Static files / host check +# --------------------------------------------------------------------------- + +def test_path_traversal_is_blocked() -> None: + client = _fresh() + for url in ("/..%2f..%2fdata%2fsettings.json", "/static/..%2f..%2fetc%2fpasswd"): + assert client.get(url).status_code == 404, url + + +def test_rebinding_hosts_are_rejected() -> None: + client = _fresh() + ok = ["localhost:8099", "127.0.0.1:8099", "192.168.1.20", "[::1]:8099", + "epaper.local", "nas", "homeassistant.home.arpa:8099"] + for host in ok: + assert client.get("/api/health", headers={"host": host}).status_code == 200, host + assert client.get("/api/health", headers={"host": "evil.example.com"}).status_code == 400 + + +def test_allowed_hosts_env() -> None: + client = _fresh() + main.ALLOWED_HOSTS[:] = ["epaper.example.com", "*.mydomain.net"] + try: + for host in ("epaper.example.com", "a.mydomain.net:443"): + assert client.get("/api/health", headers={"host": host}).status_code == 200, host + assert client.get("/api/health", headers={"host": "mydomain.net.evil.com"}).status_code == 400 + finally: + main.ALLOWED_HOSTS[:] = [] + + +# --------------------------------------------------------------------------- +# Projects +# --------------------------------------------------------------------------- + +def test_invalid_project_ids() -> None: + client = _fresh() + assert client.put("/api/projects/!!!", json={"name": "x"}).status_code == 400 + assert client.get("/api/projects/a.b").status_code == 404 + assert client.delete("/api/projects/a.b").status_code == 404 + assert not storage.PROJECTS_DIR.exists() or not list(storage.PROJECTS_DIR.glob("*.json")) + + +def test_put_does_not_create() -> None: + client = _fresh() + assert client.put("/api/projects/ghost", json={"name": "x"}).status_code == 404 + created = client.post("/api/projects", json={"name": "Real"}).json() + response = client.put(f"/api/projects/{created['id']}", json={"name": "Renamed"}) + assert response.status_code == 200 and response.json()["name"] == "Renamed" + + +def test_project_limits() -> None: + client = _fresh() + assert client.post("/api/projects", json={"width": 10**9}).status_code == 422 + assert client.post("/api/projects", json={"height": 0}).status_code == 422 + + +def test_generate_reports_warnings() -> None: + client = _fresh() + nested = {"kind": "group", "name": "outer", "repeat": {"enabled": True, "count": 2}, + "children": [{"kind": "group", "name": "inner", "children": []}]} + body = client.post("/api/generate/template", json={"nodes": [nested]}).json() + assert body["warnings"] and "inner" in body["warnings"][0], body + + +# --------------------------------------------------------------------------- +# Settings / token handling +# --------------------------------------------------------------------------- + +def test_changing_url_drops_stored_token() -> None: + client = _fresh() + client.post("/api/settings", json={"haUrl": "http://ha.local:8123", "haToken": "secret"}) + assert storage.effective_settings()["haToken"] == "secret" + + # Same URL (trailing slash, case) keeps the token. + body = client.post("/api/settings", json={"haUrl": "http://HA.local:8123/"}).json() + assert body["hasToken"] and not body["tokenCleared"], body + + body = client.post("/api/settings", json={"haUrl": "http://attacker.example"}).json() + assert not body["hasToken"] and body["tokenCleared"], body + assert storage.effective_settings()["haToken"] == "" + + +def test_env_token_is_only_sent_to_env_url() -> None: + client = _fresh() + os.environ["HA_URL"] = "http://ha.local:8123" + os.environ["HA_TOKEN"] = "env-secret" + try: + assert storage.effective_settings()["haToken"] == "env-secret" + client.post("/api/settings", json={"haUrl": "http://attacker.example"}) + assert storage.effective_settings()["haToken"] == "" + finally: + for key in ENV_KEYS: + os.environ.pop(key, None) + + +def test_settings_url_must_be_http() -> None: + client = _fresh() + assert client.post("/api/settings", json={"haUrl": "file:///etc/passwd"}).status_code == 400 + + +def test_settings_file_is_private() -> None: + client = _fresh() + client.post("/api/settings", json={"haUrl": "http://ha.local", "haToken": "t"}) + assert (storage.SETTINGS_FILE.stat().st_mode & 0o777) == 0o600 + + +def test_push_rejects_foreign_service_domains() -> None: + client = _fresh() + response = client.post("/api/ha/push", json={ + "project": {}, "deviceId": "x", "service": "homeassistant.stop", + }) + assert response.status_code == 400 and "not allowed" in response.json()["detail"] + + +def test_push_sends_service_options() -> None: + client = _fresh() + client.post("/api/settings", json={"haUrl": "http://ha.local", "haToken": "t"}) + calls = [] + rendered = [] + + async def fake_render(self, template): + rendered.append(template) + return '[{"type": "circle", "x": 1, "y": 2, "radius": 3}]' + + async def fake_call(self, domain, service, data): + calls.append((domain, service, data)) + return [] + + original_render = main.HomeAssistantClient.render_template + original = main.HomeAssistantClient.call_service + main.HomeAssistantClient.render_template = fake_render + main.HomeAssistantClient.call_service = fake_call + try: + response = client.post("/api/ha/push", json={ + "project": {"rotate": 90, "dither": 0, "ttl": 300}, "deviceId": "abc", + }) + assert response.status_code == 200, response.text + data = calls[0][2] + assert (data["rotate"], data["dither"], data["ttl"]) == (90, 0, 300), data + response = client.post("/api/ha/push", json={"project": {}, "deviceId": "abc"}) + data = calls[1][2] + assert (data["rotate"], data["dither"], data["ttl"]) == (0, 2, 60), data + finally: + main.HomeAssistantClient.render_template = original_render + main.HomeAssistantClient.call_service = original + assert client.post("/api/ha/push", json={ + "project": {"rotate": 45}, "deviceId": "abc", + }).status_code == 422 + + +def test_push_renders_before_sending() -> None: + """The integration never renders Jinja, so the payload must arrive as a list.""" + client = _fresh() + client.post("/api/settings", json={"haUrl": "http://ha.local", "haToken": "t"}) + sent = [] + templates = [] + + async def fake_render(self, template): + templates.append(template) + return '[{"type": "text", "value": "hi", "x": 0, "y": 0}]' + + async def fake_call(self, domain, service, data): + sent.append(data) + return [] + + original_render = main.HomeAssistantClient.render_template + original = main.HomeAssistantClient.call_service + main.HomeAssistantClient.render_template = fake_render + main.HomeAssistantClient.call_service = fake_call + try: + response = client.post("/api/ha/push", json={"project": {}, "deviceId": "abc"}) + assert response.status_code == 200, response.text + assert templates and "{%" in templates[0] or "[" in templates[0] + # A list, not the raw template string. + assert isinstance(sent[0]["payload"], list), sent[0]["payload"] + assert sent[0]["payload"][0]["type"] == "text" + assert response.json()["elements"] == 1 + finally: + main.HomeAssistantClient.render_template = original_render + main.HomeAssistantClient.call_service = original + + +def test_push_rejects_unrenderable_template() -> None: + client = _fresh() + client.post("/api/settings", json={"haUrl": "http://ha.local", "haToken": "t"}) + calls = [] + + async def fake_render(self, template): + return "not json at all" + + async def fake_call(self, domain, service, data): + calls.append(data) + return [] + + original_render = main.HomeAssistantClient.render_template + original = main.HomeAssistantClient.call_service + main.HomeAssistantClient.render_template = fake_render + main.HomeAssistantClient.call_service = fake_call + try: + response = client.post("/api/ha/push", json={"project": {}, "deviceId": "abc"}) + assert response.status_code == 400, response.text + assert "not render" in response.json()["detail"] + assert not calls, "nothing must be sent when the template is invalid" + finally: + main.HomeAssistantClient.render_template = original_render + main.HomeAssistantClient.call_service = original + + +# --------------------------------------------------------------------------- +# Validation endpoint +# --------------------------------------------------------------------------- + +def test_validate_ok() -> None: + client = _fresh() + body = client.post("/api/validate", json={"nodes": [ + {"kind": "element", "type": "circle", "props": {"x": 1, "y": 2, "radius": 3}}, + ]}).json() + assert body["ok"] and body["stage"] == "ok", body + assert body["count"] == 1 + assert "template" not in body, "the template is served by /api/generate/template" + + +def test_validate_reports_element_problems() -> None: + """An emptied required field is omitted by the generator and must be caught.""" + client = _fresh() + body = client.post("/api/validate", json={"nodes": [ + {"kind": "element", "type": "line", "props": {"x_start": 0, "x_end": ""}}, + ]}).json() + assert not body["ok"], body + assert body["stage"] == "elements", body + assert any("x_end" in issue for issue in body["issues"]), body + + +def test_validate_skips_ha_when_unconfigured() -> None: + client = _fresh() + body = client.post("/api/validate", json={"nodes": []}).json() + assert body["ok"] and body["source"] == "local" + + +# --------------------------------------------------------------------------- +# Pixel preview +# --------------------------------------------------------------------------- + +requires_assets = pytest.mark.skipif( + not assets_available(), + reason="font assets missing; run backend/scripts/fetch_assets.py", +) + + +@requires_assets +def test_preview_accepts_an_explicit_payload() -> None: + client = _fresh() + response = client.post("/api/preview", json={"payload": [ + {"type": "circle", "x": 20, "y": 20, "radius": 10, "fill": "red"}, + ]}) + assert response.status_code == 200, response.text + body = response.json() + assert body["image"].startswith("data:image/png;base64,") + assert body["elements"] == 1 + assert body["errors"] == [] + + +@requires_assets +def test_preview_renders_the_project_template_first() -> None: + """Templated values must be resolved before drawing, not drawn literally.""" + client = _fresh() + body = client.post("/api/preview", json={ + "project": { + "width": 200, + "height": 100, + "nodes": [ + {"kind": "element", "type": "text", + "props": {"value": "{{ 1 + 1 }}", "x": 2, "y": 2, "size": 20}}, + {"kind": "element", "type": "progress_bar", + "props": {"x_start": 0, "y_start": 50, "x_end": 100, "y_end": 70, + "progress": "{{ 25 + 25 }}"}}, + ], + }, + }).json() + assert body["errors"] == [], body + assert body["elements"] == 2 + + +@requires_assets +def test_preview_rejects_a_template_that_does_not_render() -> None: + client = _fresh() + response = client.post("/api/preview", json={ + "project": {"width": 200, "height": 100, "nodes": [ + {"kind": "element", "type": "line", + "props": {"x_start": 0, "x_end": ""}}, + ]}, + }) + assert response.status_code == 400, response.text + assert "render" in response.json()["detail"] + + +@requires_assets +def test_preview_reports_element_problems_without_failing() -> None: + client = _fresh() + body = client.post("/api/preview", json={"payload": [ + {"type": "circle", "x": 1}, + {"type": "rectangle", "x_start": 0, "x_end": 10, "y_start": 0, "y_end": 10, + "fill": "black"}, + ]}).json() + # The incomplete circle is reported by key, and the request still succeeds. + assert body["errors"], body + assert "circle" in body["errors"][0] and "missing required key" in body["errors"][0] + assert body["elements"] == 2 + + +@requires_assets +def test_preview_requires_a_source() -> None: + client = _fresh() + response = client.post("/api/preview", json={}) + assert response.status_code == 400 + assert "project" in response.json()["detail"] or "payload" in response.json()["detail"] + + +def test_preview_rejects_an_unknown_accent() -> None: + client = _fresh() + response = client.post("/api/preview", json={"payload": [], "accent": "purple"}) + assert response.status_code == 422 + + +def _run() -> None: + tests = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] + failures = 0 + for test in tests: + try: + test() + print(f" PASS {test.__name__}") + except AssertionError as exc: + failures += 1 + print(f" FAIL {test.__name__}: {exc}") + except Exception as exc: # noqa: BLE001 + failures += 1 + print(f" ERROR {test.__name__}: {type(exc).__name__}: {exc}") + print(f"\n{len(tests) - failures}/{len(tests)} passed") + raise SystemExit(1 if failures else 0) + + +if __name__ == "__main__": + _run() diff --git a/backend/app/test_generator.py b/backend/app/test_generator.py index 0ee9949..d12b356 100644 --- a/backend/app/test_generator.py +++ b/backend/app/test_generator.py @@ -9,7 +9,13 @@ from jinja2 import Environment -from .generator import generate_template +from .generator import ( + collect_warnings, + generate_payload, + generate_template, + generate_yaml, +) +from .schema import default_props ICON_MAP = {"sunny": "mdi:weather-sunny", "rainy": "mdi:weather-rainy"} @@ -279,6 +285,147 @@ def test_static_output_has_no_runtime_guard() -> None: assert "namespace(first=true)" not in static, static +def test_percentage_positions() -> None: + """`x: "50%"` is documented drawcustom syntax and must stay a string.""" + payload = _render(generate_template(_project(_text(x="50%", y="12.5%")))) + assert payload[0]["x"] == "50%" and payload[0]["y"] == "12.5%", payload + + +def _element(etype: str, **props: object) -> dict: + return {"kind": "element", "type": etype, "props": {**default_props(etype), **props}} + + +def test_dynamic_defaults_are_always_emitted() -> None: + """Fields whose HA default is dynamic must never be omitted. + + Omitting `y_end: 0` makes HA draw to `y_start`; omitting a plot's box makes + it span the whole canvas; omitting `multiline.y` auto-positions it. + """ + payload = _render(generate_template(_project( + _element("line", y_start=60, y_end=0), + _element("plot"), + _element("multiline", y=0), + _element("debug_grid", spacing=10), + ))) + line, plot, multiline, grid = payload + assert line["y_start"] == 60 and line["y_end"] == 0, line + assert {k: plot[k] for k in ("x_start", "y_start", "x_end", "y_end")} == \ + {"x_start": 10, "y_start": 20, "x_end": 199, "y_end": 119}, plot + assert multiline["y"] == 0, multiline + assert grid["label_step"] == 40, grid + + +def test_plot_duration_default_matches_home_assistant() -> None: + """Default duration is HA's 86400, so leaving it untouched is correct.""" + payload = _render(generate_template(_project(_element("plot")))) + assert "duration" not in payload[0], payload + payload = _render(generate_template(_project(_element("plot", duration=3600)))) + assert payload[0]["duration"] == 3600, payload + + +def test_yaml_with_jinja_is_valid_yaml() -> None: + import yaml + + project = _project(_text(value='{{ states("sensor.t") }} °C', x="50%", color="#fff")) + parsed = yaml.safe_load(generate_yaml(project)) + assert parsed[0]["value"] == '{{ states("sensor.t") }} °C', parsed + assert parsed[0]["x"] == "50%" and parsed[0]["color"] == "#fff", parsed + + +def _values(project: dict) -> list: + return [item["value"] for item in _render(generate_template(project))] + + +def test_nested_loops() -> None: + """A loop inside a loop expands to outer × inner items.""" + inner = {**_group([_text(value="{{ i }}{{ j }}")], count=3, var="j"), "name": "inner"} + outer = _group([_text(value="h{{ i }}"), inner], count=2) + assert _values(_project(outer)) == ["h0", "00", "01", "02", "h1", "10", "11", "12"] + + +def test_nested_loop_first_with_hidden_and_trailing_nodes() -> None: + inner = _group([_text(visible=False), _text(value="{{ i }}{{ j }}")], count=2, var="j") + outer = _group([inner], count=2) + assert _values(_project(outer, _text(value="end"))) == ["00", "01", "10", "11", "end"] + + +def test_nested_zero_and_empty_groups() -> None: + empty = _group([_text(visible=False)], var="j") + zero = _group([_text()], count=0, var="k") + outer = _group([empty, zero, _text(value="x")], count=2) + assert _values(_project(_text(value="a"), outer)) == ["a", "x", "x"] + + +def test_loop_inside_disabled_group() -> None: + loop = _group([_text(value="{{ i }}")], count=2) + wrapper = _group([_text(value="w"), loop], enabled=False) + assert _values(_project(wrapper)) == ["w", "0", "1"] + + +def test_nested_static_payload_flattens_disabled_groups() -> None: + wrapper = _group([_text(value="w"), _group([_text(value="z")], enabled=False)], enabled=False) + assert [p["value"] for p in generate_payload(_project(wrapper))] == ["w", "z"] + + +def test_nested_group_reusing_loop_variable_is_reported() -> None: + inner = {**_group([_text()]), "name": "inner"} + project = _project({**_group([inner]), "name": "outer"}) + warnings = collect_warnings(project) + assert len(warnings) == 1 and "inner" in warnings[0] and '"i"' in warnings[0], warnings + assert collect_warnings(_weather_project()) == [] + + +def test_user_ns_variable_does_not_clash_with_guard() -> None: + project = _project(_group([_text(value="{{ ns.v }}")], count="{{ n }}")) + project["variables"] = [{"name": "ns", "value": "namespace(v='u')"}] + assert _values(project) == ["u", "u", "u"] + + +def test_plot_axes_and_legends() -> None: + plot = _element("plot", yaxis={"grid_style": "lines"}, ylegend={}, xaxis=None, + xlegend={"format": "%H", "interval": ""}) + item = _render(generate_template(_project(plot)))[0] + assert item["yaxis"] == {"grid_style": "lines"}, item + # All-default objects must not become `{}`, which HA treats as "off". + assert item["ylegend"] == {"position": "left"}, item + assert item["xlegend"] == {"format": "%H"}, item + assert "xaxis" not in item, item + static = generate_payload(_project(plot))[0] + assert static["ylegend"] == {"position": "left"} and "xaxis" not in static, static + + +def test_plot_axis_accepts_jinja() -> None: + plot = _element("plot", yaxis={"tick_every": "{{ n }}"}) + assert _render(generate_template(_project(plot)))[0]["yaxis"] == {"tick_every": 3} + + +def test_corners_force_radius() -> None: + """HA rounds with radius 10 when `corners` comes without `radius`.""" + item = _render(generate_template(_project(_element("rectangle", corners="top_left"))))[0] + assert item["corners"] == "top_left" and item["radius"] == 0, item + item = _render(generate_template(_project(_element("rectangle"))))[0] + assert "corners" not in item and "radius" not in item, item + item = _render(generate_template(_project(_element("rectangle_pattern", corners="top_left"))))[0] + assert item["radius"] == 0, item + + +def test_text_anchor_forced_for_multiline_text() -> None: + """HA uses "la" instead of "lt" for multi-line text without an anchor.""" + item = _render(generate_template(_project(_text(value="a\nb"))))[0] + assert item["anchor"] == "lt", item + item = _render(generate_template(_project(_text(max_width=50))))[0] + assert item["anchor"] == "lt", item + item = _render(generate_template(_project(_text(value="ab"))))[0] + assert "anchor" not in item, item + + +def test_multiline_anchor_default_matches_home_assistant() -> None: + item = _render(generate_template(_project(_element("multiline"))))[0] + assert "anchor" not in item, item # HA default "lm" == schema default + item = _render(generate_template(_project(_element("multiline", anchor="lt"))))[0] + assert item["anchor"] == "lt", item + + def _run() -> None: tests = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] failures = 0 @@ -297,4 +444,4 @@ def _run() -> None: if __name__ == "__main__": - _run() \ No newline at end of file + _run() diff --git a/backend/app/test_render.py b/backend/app/test_render.py new file mode 100644 index 0000000..64f1347 --- /dev/null +++ b/backend/app/test_render.py @@ -0,0 +1,309 @@ +"""Tests for the pixel-accurate preview renderer. + +Run with: python -m pytest -q app +""" + +from __future__ import annotations + +import io + +import pytest +from PIL import Image + +from .render import ColorResolver, assets_available, icon_glyph, render_payload +from .render.colors import BLACK, HALF_RED, RED, WHITE, YELLOW +from .render.coordinates import parse_dimension + +pytestmark = pytest.mark.skipif( + not assets_available(), + reason="font assets missing; run backend/scripts/fetch_assets.py", +) + + +def _render(payload: list[dict], **kwargs) -> tuple[Image.Image, list[str], list[str]]: + width = kwargs.pop("width", 200) + height = kwargs.pop("height", 100) + png, errors, notes = render_payload(payload, width=width, height=height, **kwargs) + return Image.open(io.BytesIO(png)).convert("RGB"), errors, notes + + +# --------------------------------------------------------------------------- +# Colors +# --------------------------------------------------------------------------- + +def test_named_colors_and_shortcuts() -> None: + resolver = ColorResolver("red") + assert resolver.resolve("black") == BLACK + assert resolver.resolve("b") == BLACK + assert resolver.resolve("white") == WHITE + assert resolver.resolve("red") == RED + assert resolver.resolve("yellow") == YELLOW + assert resolver.resolve("half_red") == HALF_RED + assert resolver.resolve("hr") == HALF_RED + assert resolver.resolve("gray") == resolver.resolve("grey") + + +def test_accent_follows_the_tag() -> None: + assert ColorResolver("red").resolve("accent") == RED + assert ColorResolver("yellow").resolve("accent") == YELLOW + assert ColorResolver("yellow").resolve("ha") == ColorResolver("yellow").resolve("half_accent") + + +def test_hex_colors() -> None: + resolver = ColorResolver() + assert resolver.resolve("#f00") == (255, 0, 0, 255) + assert resolver.resolve("#ff0000") == (255, 0, 0, 255) + assert resolver.resolve("#00ff00") == (0, 255, 0, 255) + # Malformed hex falls back to white rather than raising. + assert resolver.resolve("#zz") == WHITE + + +def test_none_means_no_color() -> None: + assert ColorResolver().resolve(None) is None + assert ColorResolver().resolve("") is None + + +# --------------------------------------------------------------------------- +# Coordinates +# --------------------------------------------------------------------------- + +def test_parse_dimension() -> None: + assert parse_dimension(50, 200) == 50 + assert parse_dimension("50", 200) == 50 + assert parse_dimension("50%", 200) == 100 + assert parse_dimension("12.7", 200) == 12 + assert parse_dimension("nonsense", 200) == 0 + assert parse_dimension(None, 200) == 0 + # bools are not numbers here, they would otherwise become 1/0 silently. + assert parse_dimension(True, 200) == 0 + + +# --------------------------------------------------------------------------- +# Rendering +# --------------------------------------------------------------------------- + +def test_canvas_size_and_background() -> None: + image, errors, _ = _render([], width=296, height=128) + assert image.size == (296, 128) + assert not errors + assert image.getpixel((0, 0)) == (255, 255, 255) + + +def test_black_background() -> None: + image, _, _ = _render([], background="black") + assert image.getpixel((0, 0)) == (0, 0, 0) + + +def test_text_is_drawn() -> None: + image, errors, _ = _render( + [{"type": "text", "value": "Hi", "x": 2, "y": 2, "size": 40}] + ) + assert not errors + # Some pixel in the top-left quadrant must be dark. + dark = sum( + 1 + for x in range(0, 80) + for y in range(0, 50) + if sum(image.getpixel((x, y))) < 200 + ) + assert dark > 10, "text did not render" + + +def test_rotation_keeps_the_declared_canvas_size() -> None: + """Rotation changes how coordinates are interpreted, not the output size. + + The integration allocates a swapped canvas for 90/270, draws into it and + rotates back with `expand=True`, so the final image is always width x height + while the content is laid out in the rotated frame. + """ + image, _, _ = _render([], width=296, height=128, rotate=90) + assert image.size == (296, 128) + + +def test_rotation_moves_drawn_content() -> None: + """Rotating reinterprets coordinates, so content lands elsewhere. + + A 90 degree rotation draws into a swapped canvas and rotates back, which + moves a mark at the origin to the right-hand edge. + """ + upright, _, _ = _render( + [{"type": "rectangle", "x_start": 0, "x_end": 10, "y_start": 0, "y_end": 10, + "fill": "black"}], + width=100, height=50, rotate=0, + ) + rotated, _, _ = _render( + [{"type": "rectangle", "x_start": 0, "x_end": 10, "y_start": 0, "y_end": 10, + "fill": "black"}], + width=100, height=50, rotate=90, + ) + assert upright.getpixel((5, 5)) == (0, 0, 0) + assert rotated.getpixel((5, 5)) == (255, 255, 255), "content should have moved" + assert rotated.getpixel((95, 5)) == (0, 0, 0), "content should be at the right edge" + + +def test_unknown_element_is_reported_not_raised() -> None: + image, errors, _ = _render([{"type": "not_a_type"}]) + assert image.size == (200, 100) + assert errors and "unknown type" in errors[0] + + +def test_missing_required_key_is_reported() -> None: + _, errors, _ = _render([{"type": "circle", "x": 1, "y": 2}]) + assert errors and "radius" in errors[0] + + +def test_one_bad_element_does_not_stop_the_rest() -> None: + image, errors, _ = _render([ + {"type": "circle", "x": 1, "y": 2}, # missing radius + {"type": "rectangle", "x_start": 0, "x_end": 50, "y_start": 0, "y_end": 50, + "fill": "black"}, + ]) + assert errors, "the broken element should be reported" + assert image.getpixel((25, 25)) == (0, 0, 0), "the good element should still draw" + + +def test_hidden_elements_are_skipped() -> None: + image, errors, _ = _render([ + {"type": "rectangle", "x_start": 0, "x_end": 50, "y_start": 0, "y_end": 50, + "fill": "black", "visible": False}, + ]) + assert not errors + assert image.getpixel((25, 25)) == (255, 255, 255) + + +def test_percentage_coordinates() -> None: + image, _, _ = _render( + [{"type": "circle", "x": "50%", "y": "50%", "radius": 5, "fill": "black"}], + width=200, height=100, + ) + assert image.getpixel((100, 50)) == (0, 0, 0) + + +def test_progress_bar_fill_direction() -> None: + image, _, _ = _render([{ + "type": "progress_bar", + "x_start": 0, "y_start": 0, "x_end": 100, "y_end": 20, + "progress": 50, "fill": "red", "outline": "black", + }]) + # Half-filled: left side red, right side still the white background. + assert image.getpixel((10, 10))[0] > 200 and image.getpixel((10, 10))[1] < 100 + assert image.getpixel((90, 10)) == (255, 255, 255) + + +def test_progress_is_clamped() -> None: + image, _, _ = _render([{ + "type": "progress_bar", + "x_start": 0, "y_start": 0, "x_end": 100, "y_end": 20, + "progress": 500, "fill": "red", "outline": "black", + }]) + assert image.getpixel((95, 10))[0] > 200, "progress must clamp to 100" + + +def test_polygon_ignores_width_like_the_integration() -> None: + """The integration always strokes polygons 1px; the preview must match.""" + _, errors, _ = _render([{ + "type": "polygon", "points": [[10, 10], [50, 10], [30, 40]], + "outline": "black", "width": 9, + }]) + assert not errors + + +def test_icon_glyph_resolution() -> None: + assert icon_glyph("mdi:home") is not None + assert icon_glyph("home") is not None + assert icon_glyph("mdi:definitely-not-an-icon") is None + + +def test_icon_renders_something() -> None: + image, errors, _ = _render( + [{"type": "icon", "value": "mdi:home", "x": 2, "y": 2, "size": 48}] + ) + assert not errors + dark = sum( + 1 + for x in range(0, 60) + for y in range(0, 60) + if sum(image.getpixel((x, y))) < 200 + ) + assert dark > 20, "icon did not render" + + +def test_unknown_icon_is_reported() -> None: + _, errors, _ = _render( + [{"type": "icon", "value": "mdi:not-real", "x": 0, "y": 0, "size": 20}] + ) + assert errors and "not-real" in errors[0] + + +def test_qrcode_is_drawn() -> None: + image, errors, _ = _render( + [{"type": "qrcode", "data": "https://example.com", "x": 2, "y": 2}] + ) + assert not errors + dark = sum( + 1 + for x in range(0, 60) + for y in range(0, 60) + if sum(image.getpixel((x, y))) < 200 + ) + assert dark > 20, "QR code did not render" + + +def test_plot_frame_and_note() -> None: + """A plot cannot fetch history, so it draws a frame and says so.""" + _, errors, notes = _render([{ + "type": "plot", "x_start": 0, "y_start": 0, "x_end": 100, "y_end": 60, + "data": [{"entity": "sensor.temperature"}], + }]) + assert not errors + assert notes and "history" in notes[0] + + +def test_remote_image_is_a_note_not_an_error() -> None: + _, errors, notes = _render([{ + "type": "dlimg", "url": "https://example.com/x.png", + "x": 0, "y": 0, "xsize": 40, "ysize": 40, + }]) + assert not errors + assert notes and "remote" in notes[0] + + +def test_color_markup_renders() -> None: + image, errors, _ = _render([{ + "type": "text", "value": "[red]R[/red]", "x": 2, "y": 2, + "size": 40, "parse_colors": True, + }]) + assert not errors + reddish = sum( + 1 + for x in range(0, 60) + for y in range(0, 50) + if image.getpixel((x, y))[0] > 150 and image.getpixel((x, y))[1] < 100 + ) + assert reddish > 5, "color markup did not apply" + + +def test_multiline_uses_delimiter() -> None: + _, errors, _ = _render([{ + "type": "multiline", "value": "A|B|C", "delimiter": "|", + "x": 0, "y": 0, "offset_y": 20, "size": 12, + }]) + assert not errors + + +def test_invalid_canvas_is_rejected() -> None: + from .render import RenderError + + with pytest.raises(RenderError): + render_payload([], width=0, height=10) + + +def test_output_is_a_paletted_png() -> None: + """E-paper is 1-bit per channel, so the preview is quantised too.""" + png, _, _ = render_payload( + [{"type": "circle", "x": 20, "y": 20, "radius": 10, "fill": "red"}], + width=60, height=60, + ) + image = Image.open(io.BytesIO(png)) + assert image.format == "PNG" + assert image.mode == "P" diff --git a/backend/app/test_templating.py b/backend/app/test_templating.py new file mode 100644 index 0000000..80f696f --- /dev/null +++ b/backend/app/test_templating.py @@ -0,0 +1,252 @@ +"""Tests for template rendering and validation. + +Run with: python -m pytest -q app +""" + +from __future__ import annotations + +import asyncio +import json + +from .templating import ( + RenderFailed, + parse_rendered, + payload_from_rendered, + render_locally, + validate_project, +) + + +def _render(project: dict) -> dict: + return asyncio.run(validate_project(project)) + + +def _el(etype: str, props: dict) -> dict: + return {"kind": "element", "type": etype, "props": props} + + +def _weather() -> dict: + return { + "variables": [{"name": "spacing", "value": "49"}], + "nodes": [ + _el("text", {"value": " ", "x": 0, "y": 0, "size": 1, "color": "yellow"}), + { + "kind": "group", + "repeat": {"enabled": True, "var": "i", "count": 8, "pre": []}, + "children": [ + _el("text", {"value": "{{ times[i] }}", + "x": "{{ 15 + i*spacing }}", "y": 5, "size": 10}), + ], + }, + ], + } + + +# --------------------------------------------------------------------------- +# Local sandbox +# --------------------------------------------------------------------------- + +def test_undefined_names_render_as_zero() -> None: + """Numeric slots must not become empty strings, which would break JSON.""" + assert render_locally("[{{ offset }}, {{ a.b.c }}, {{ x[0] }}]") == "[0, 0, 0]" + + +def test_unknown_filters_and_functions_are_permissive() -> None: + assert render_locally("{{ states('sensor.x') }}") == "0" + assert render_locally("{{ x | made_up_filter }}") == "0" + # Real Jinja filters keep working, and behave as they would in Home + # Assistant against an undefined value. + assert render_locally("{{ x | default(5) }}") == "5" + assert render_locally("{{ x | int(0) }}") == "0" + assert render_locally("{{ x | int(7) + 1 }}") == "1" + + +def test_arithmetic_on_undefined_does_not_raise() -> None: + assert render_locally("{{ a + b * c }}") == "0" + assert render_locally("{{ -missing }}") == "0" + + +def test_real_values_still_render() -> None: + assert render_locally("{% set x = 4 %}{{ x * 2 }}") == "8" + + +def test_syntax_error_is_reported_with_line() -> None: + try: + render_locally("{% for i in range(3) %}") + except RenderFailed as exc: + assert "line 1" in str(exc), exc + else: + raise AssertionError("expected RenderFailed") + + +def test_runtime_error_is_reported() -> None: + try: + render_locally("{{ 1 / 0 }}") + except RenderFailed as exc: + assert "ZeroDivisionError" in str(exc) + else: + raise AssertionError("expected RenderFailed") + + +def test_sandbox_blocks_attribute_escape() -> None: + """The environment must stay sandboxed: no reaching into Python internals.""" + try: + render_locally("{{ ''.__class__.__mro__ }}") + except RenderFailed: + pass # blocked at compile time by the sandbox + else: + # If it renders, it must not have leaked a class object. + assert "__class__" not in render_locally("{{ ''.__class__ }}") + + +# --------------------------------------------------------------------------- +# parse_rendered +# --------------------------------------------------------------------------- + +def test_valid_payload_passes() -> None: + result = parse_rendered('[{"type": "circle", "x": 1, "y": 2, "radius": 3}]') + assert result["ok"] and result["stage"] == "ok" + assert result["payload"] == [{"type": "circle", "x": 1, "y": 2, "radius": 3}] + + +def test_empty_render_is_a_render_failure() -> None: + result = parse_rendered(" ") + assert not result["ok"] and result["stage"] == "render" + + +def test_invalid_json_reports_position_and_excerpt() -> None: + result = parse_rendered('[{"type": "text", "value": "a" "b"}]') + assert not result["ok"] and result["stage"] == "parse" + assert "column" in result["error"] + assert "^" in result["excerpt"] + + +def test_excerpt_points_at_the_error_column() -> None: + """The caret must sit under the offending character, not the line start.""" + text = '[\n {\n "value": """,\n "x": 6\n }\n]' + result = parse_rendered(text) + caret_line = next( + line for line in result["excerpt"].split("\n") if line.strip() == "^" + ) + # The caret is indented to the column reported in the message. + column = int(result["error"].rsplit("column ", 1)[1].rstrip(")")) - 1 + assert caret_line.index("^") == column, (result["excerpt"], column) + + +def test_excerpt_caret_follows_the_error_line() -> None: + """With a multi-line error the caret sits directly under its own line.""" + text = '[\n {"a": 1},\n {bad!},\n {"b": 2}\n]' + result = parse_rendered(text) + lines = result["excerpt"].split("\n") + caret_index = next(i for i, line in enumerate(lines) if line.strip() == "^") + # The line above the caret is the one the error was reported on. + assert "{bad!}" in lines[caret_index - 1], lines + + +def test_excerpt_keeps_newlines_readable() -> None: + """The snippet is shown in a
, so it must not escape its newlines."""
+    result = parse_rendered('[\n  {oops}\n]')
+    assert "\\n" not in result["excerpt"]
+    assert "\n" in result["excerpt"]
+
+
+def test_non_array_is_an_element_failure() -> None:
+    result = parse_rendered('{"a": 1}')
+    assert not result["ok"] and result["stage"] == "elements"
+    assert "array" in result["error"]
+
+
+def test_unknown_type_is_reported() -> None:
+    result = parse_rendered('[{"type": "not_a_type"}]')
+    assert not result["ok"] and result["stage"] == "elements"
+    assert "not_a_type" in result["issues"][0]
+
+
+def test_missing_required_keys_are_reported_per_element() -> None:
+    """The key lists mirror the integration's @element_handler(requires=...)."""
+    result = parse_rendered(json.dumps([
+        {"type": "line", "x_start": 0},
+        {"type": "circle", "x": 1, "y": 2, "radius": 3},
+        {"type": "multiline", "x": 1},
+    ]))
+    assert not result["ok"]
+    issues = "\n".join(result["issues"])
+    assert "line" in issues and "'x_end'" in issues
+    assert "'delimiter'" in issues and "'offset_y'" in issues and "'value'" in issues
+    # The complete circle must not be reported.
+    assert "circle" not in issues
+
+
+def test_non_list_icon_field_is_reported() -> None:
+    result = parse_rendered('[{"type": "icon_sequence", "x": 0, "y": 0, '
+                            '"icons": "mdi:home", "size": 20}]')
+    assert not result["ok"]
+    assert "must be a list" in result["issues"][0]
+
+
+# ---------------------------------------------------------------------------
+# validate_project
+# ---------------------------------------------------------------------------
+
+def test_weather_project_validates() -> None:
+    result = _render(_weather())
+    assert result["ok"], result
+    assert result["source"] == "local"
+    # 1 literal + 8 loop iterations.
+    assert result["count"] == 9
+
+
+def test_every_issue_is_capped() -> None:
+    """A payload full of broken elements must not produce an unbounded list."""
+    payload = json.dumps([{"type": "circle"} for _ in range(60)])
+    result = parse_rendered(payload)
+    assert not result["ok"]
+    assert len(result["issues"]) == 21, len(result["issues"])
+    assert "omitted" in result["issues"][-1]
+
+
+def test_nested_groups_validate() -> None:
+    project = {
+        "nodes": [{
+            "kind": "group",
+            "repeat": {"enabled": True, "var": "i", "count": 2, "pre": []},
+            "children": [{
+                "kind": "group",
+                "repeat": {"enabled": True, "var": "j", "count": 3, "pre": []},
+                "children": [_el("circle", {"x": "{{ i*10 + j }}", "y": 0, "radius": 2})],
+            }],
+        }],
+    }
+    result = _render(project)
+    assert result["ok"], result
+    assert result["count"] == 6
+
+
+def test_payload_from_rendered_returns_none_on_failure() -> None:
+    payload, check = payload_from_rendered("[{oops}]")
+    assert payload is None and not check["ok"]
+    payload, check = payload_from_rendered("[]")
+    assert payload == [] and check["ok"]
+
+
+def test_ha_client_result_is_used_when_available() -> None:
+    """A reachable Home Assistant is the authoritative renderer."""
+    class FakeClient:
+        async def render_template(self, template: str) -> str:
+            assert "{%" in template or "[" in template
+            return '[{"type": "circle", "x": 1, "y": 2, "radius": 3}]'
+
+    result = asyncio.run(validate_project({"nodes": []}, FakeClient()))
+    assert result["ok"] and result["source"] == "ha"
+    assert result["count"] == 1
+
+
+def test_falls_back_to_local_when_ha_fails() -> None:
+    class BrokenClient:
+        async def render_template(self, template: str) -> str:
+            raise RuntimeError("connection refused")
+
+    result = asyncio.run(validate_project(_weather(), BrokenClient()))
+    assert result["ok"], result
+    assert result["source"] == "local"
+    assert "connection refused" in result["haError"]
diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt
index 6c861d6..feff1d8 100644
--- a/backend/requirements-dev.txt
+++ b/backend/requirements-dev.txt
@@ -2,4 +2,6 @@
 # The generator itself emits Jinja text; these are needed to render it and
 # assert the result is valid JSON.
 -r requirements.txt
-jinja2>=3.1,<4.0
+pyyaml>=6.0,<7.0
+pytest>=8.0,<9.0
+ruff>=0.8,<1.0
diff --git a/backend/requirements.txt b/backend/requirements.txt
index bae92aa..256371b 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -2,4 +2,10 @@ fastapi>=0.115,<1.0
 uvicorn[standard]>=0.34,<1.0
 httpx>=0.28,<1.0
 pydantic>=2.10,<3.0
-python-dotenv>=1.0,<2.0
\ No newline at end of file
+python-dotenv>=1.0,<2.0
+# Renders generated templates locally for /api/validate when Home Assistant
+# is unreachable. Also used by the generator tests.
+jinja2>=3.1,<4.0
+# Pixel-accurate preview rendering (POST /api/preview).
+pillow>=10.0,<13.0
+qrcode>=7.4,<9.0
\ No newline at end of file
diff --git a/backend/ruff.toml b/backend/ruff.toml
new file mode 100644
index 0000000..c8fab4d
--- /dev/null
+++ b/backend/ruff.toml
@@ -0,0 +1,18 @@
+# Keep lint stable between local runs and CI. Without a config file ruff's
+# defaults can change with a new release, which would turn CI red for reasons
+# unrelated to the change under review.
+line-length = 100
+target-version = "py311"
+
+[lint]
+# E/W: pycodestyle, F: pyflakes, I: import sorting, UP: pyupgrade,
+# B: bugbear, C4: comprehensions, SIM: simplify.
+select = ["E", "W", "F", "I", "UP", "B", "C4", "SIM"]
+ignore = [
+    # Line length is enforced by the formatter, not by the linter.
+    "E501",
+]
+
+[lint.per-file-ignores]
+# Tests may use long literals and deliberately broad assertions.
+"app/test_*.py" = ["B011"]
diff --git a/backend/scripts/fetch_assets.py b/backend/scripts/fetch_assets.py
new file mode 100644
index 0000000..da4b476
--- /dev/null
+++ b/backend/scripts/fetch_assets.py
@@ -0,0 +1,141 @@
+#!/usr/bin/env python3
+"""Download the fonts used by the pixel-perfect preview.
+
+Run at Docker build time (and manually when developing) so the repository does
+not have to carry ~4.5 MB of binary assets. Every file is verified against its
+expected size *and* git blob SHA-1 before it is accepted, so a moved tag, a
+truncated download or a tampered mirror is rejected rather than silently
+producing a subtly wrong preview.
+
+Usage::
+
+    python scripts/fetch_assets.py            # download what is missing
+    python scripts/fetch_assets.py --check    # verify only, download nothing
+    python scripts/fetch_assets.py --force    # re-download everything
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import sys
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+# The same files the integration ships in
+# custom_components/open_epaper_link/imagegen/assets, pinned to the revision the
+# renderer was written against.
+SOURCE_REVISION = "f28c21761365181cfaf203030678f1c29433baf3"
+SOURCE_BASE = (
+    "https://raw.githubusercontent.com/OpenEPaperLink/Home_Assistant_Integration/"
+    f"{SOURCE_REVISION}/custom_components/open_epaper_link/imagegen/assets"
+)
+
+ASSETS_DIR = Path(__file__).resolve().parents[1] / "app" / "assets"
+
+# name -> (size in bytes, git blob SHA-1)
+ASSETS: dict[str, tuple[int, str]] = {
+    "ppb.ttf": (153944, "00559eeb290fb8036f10633ff0640447d827b27c"),
+    "rbm.ttf": (168644, "ac0f908b9c9c73da558b45d65cc5c6094874d3e8"),
+    "materialdesignicons-webfont.ttf": (
+        1279992,
+        "b00c684d3ef14be87f0badd2eecc88babc70fea0",
+    ),
+    "materialdesignicons-webfont_meta.json": (
+        3164954,
+        "b1f439079270016fb50502ba531736f4cae20790",
+    ),
+}
+
+
+def git_blob_sha1(data: bytes) -> str:
+    """The SHA-1 git reports for a blob, i.e. over `blob \\0`."""
+    header = f"blob {len(data)}\0".encode()
+    return hashlib.sha1(header + data).hexdigest()
+
+
+def verify(path: Path, size: int, sha: str) -> tuple[bool, str]:
+    """Check a file against its expected size and hash."""
+    if not path.exists():
+        return False, "missing"
+    data = path.read_bytes()
+    if len(data) != size:
+        return False, f"size {len(data)} != {size}"
+    actual = git_blob_sha1(data)
+    if actual != sha:
+        return False, f"sha1 {actual} != {sha}"
+    return True, "ok"
+
+
+def download(name: str, expected_size: int, expected_sha: str, *, force: bool) -> bool:
+    target = ASSETS_DIR / name
+
+    if not force:
+        ok, reason = verify(target, expected_size, expected_sha)
+        if ok:
+            print(f"  ok       {name} (already present)")
+            return True
+        if target.exists():
+            print(f"  stale    {name}: {reason}")
+
+    url = f"{SOURCE_BASE}/{name}"
+    print(f"  fetch    {name} <- {url}")
+    try:
+        with urllib.request.urlopen(url, timeout=120) as response:  # noqa: S310
+            data = response.read()
+    except (urllib.error.URLError, TimeoutError) as exc:
+        print(f"  FAILED   {name}: {exc}")
+        return False
+
+    if len(data) != expected_size:
+        print(f"  FAILED   {name}: downloaded {len(data)} bytes, expected {expected_size}")
+        return False
+
+    actual_sha = git_blob_sha1(data)
+    if actual_sha != expected_sha:
+        print(f"  FAILED   {name}: sha1 {actual_sha}, expected {expected_sha}")
+        return False
+
+    ASSETS_DIR.mkdir(parents=True, exist_ok=True)
+    target.write_bytes(data)
+    print(f"  saved    {name} ({len(data)} bytes)")
+    return True
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        "--check",
+        action="store_true",
+        help="verify existing files without downloading",
+    )
+    parser.add_argument(
+        "--force",
+        action="store_true",
+        help="re-download even when a verified file is present",
+    )
+    args = parser.parse_args()
+
+    print(f"Assets directory: {ASSETS_DIR}")
+    failures: list[str] = []
+
+    for name, (size, sha) in ASSETS.items():
+        if args.check:
+            ok, reason = verify(ASSETS_DIR / name, size, sha)
+            print(f"  {'ok      ' if ok else 'MISSING '} {name}: {reason}")
+        else:
+            ok = download(name, size, sha, force=args.force)
+        if not ok:
+            failures.append(name)
+
+    if failures:
+        print(f"\n{len(failures)} asset(s) could not be prepared: {', '.join(failures)}")
+        return 1
+
+    print("\nAll assets verified.")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index 4ea92c0..06b3480 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -3,4 +3,4 @@ services:
     volumes:
       - ./backend/app:/app/app
       - ./frontend:/app/frontend
-    command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
+    command: ["sh", "-c", "exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload --log-level \"${LOG_LEVEL:-info}\""]
diff --git a/docker-compose.yml b/docker-compose.yml
index 598f16d..6356166 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -13,6 +13,10 @@ services:
       HA_TOKEN: "${HA_TOKEN:-}"
       # --- Storage ---
       DATA_DIR: "/data"
+      # --- Security (see README > Security) ---
+      CORS_ORIGINS: "${CORS_ORIGINS:-}"
+      ALLOWED_HOSTS: "${ALLOWED_HOSTS:-}"
+      ALLOWED_SERVICE_DOMAINS: "${ALLOWED_SERVICE_DOMAINS:-}"
       # --- Misc ---
       LOG_LEVEL: "${LOG_LEVEL:-info}"
     volumes:
diff --git a/frontend/css/styles.css b/frontend/css/styles.css
index 2b0249a..b360148 100644
--- a/frontend/css/styles.css
+++ b/frontend/css/styles.css
@@ -542,4 +542,108 @@ code { font-family: var(--mono); background: var(--bg-elev-2); padding: 1px 4px;
   .panel-left { width: 172px; }
   .panel-right { width: 280px; }
   .canvas-toolbar { gap: 8px; padding: 6px 10px; }
-}
\ No newline at end of file
+}
+.code-warnings {
+  margin: 0 0 8px;
+  padding: 8px 10px;
+  border-radius: 6px;
+  border: 1px solid var(--warning);
+  background: rgba(255, 184, 77, 0.1);
+  color: var(--warning);
+  font-size: 12px;
+  line-height: 1.4;
+}
+
+.code-status {
+  margin: 0 0 8px;
+  padding: 8px 10px;
+  border-radius: 6px;
+  font-size: 12px;
+  line-height: 1.45;
+  border: 1px solid var(--border-strong);
+  background: var(--bg-elev-2);
+  color: var(--text-dim);
+}
+.code-status.is-ok { border-color: var(--success); background: rgba(62, 207, 142, 0.1); color: var(--success); }
+.code-status.is-error { border-color: var(--danger); background: rgba(255, 92, 92, 0.1); color: var(--danger); }
+.code-status.is-pending { border-color: var(--border-strong); }
+.code-status .status-head { font-weight: 600; }
+.code-status .status-detail { margin-top: 4px; color: var(--text-dim); }
+.code-status pre {
+  margin: 6px 0 0;
+  padding: 6px 8px;
+  background: rgba(0, 0, 0, 0.25);
+  border-radius: 4px;
+  font-family: var(--mono);
+  font-size: 11px;
+  white-space: pre;
+  overflow-x: auto;
+  color: var(--text);
+}
+.code-status ul { margin: 6px 0 0; padding-left: 18px; }
+.code-status li { margin-bottom: 2px; }
+
+/* ---------------------------------------------------------------- pixel preview */
+
+.preview-wrap { display: flex; flex-direction: column; gap: 12px; }
+.preview-toolbar {
+  display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
+  padding-bottom: 10px; border-bottom: 1px solid var(--border);
+}
+.preview-spacer { flex: 1; }
+.preview-stage {
+  display: grid; place-items: center;
+  padding: 16px;
+  border-radius: var(--radius-sm);
+  background: var(--bg-elev-2);
+  overflow: auto;
+  max-height: 60vh;
+}
+/* An e-paper panel has no backlight, so a checkerboard makes it obvious which
+   pixels are actually dark versus merely white. */
+.preview-stage.is-checkered {
+  background-image:
+    linear-gradient(45deg, #2a2f3a 25%, transparent 25%, transparent 75%, #2a2f3a 75%),
+    linear-gradient(45deg, #2a2f3a 25%, transparent 25%, transparent 75%, #2a2f3a 75%);
+  background-size: 16px 16px;
+  background-position: 0 0, 8px 8px;
+}
+.preview-image {
+  display: block;
+  box-shadow: 0 4px 18px rgba(0, 0, 0, 0.5);
+  outline: 1px solid #000;
+}
+.preview-issues {
+  margin-top: 10px;
+  padding: 8px 10px;
+  border-radius: 6px;
+  font-size: 12px;
+  line-height: 1.45;
+}
+.preview-issues.is-error {
+  border: 1px solid var(--danger);
+  background: rgba(255, 92, 92, 0.1);
+  color: var(--danger);
+}
+.preview-issues.is-note {
+  border: 1px solid var(--warning);
+  background: rgba(255, 184, 77, 0.1);
+  color: var(--warning);
+}
+.preview-issues ul { margin: 6px 0 0; padding-left: 18px; }
+.preview-issues li { margin-bottom: 2px; }
+.preview-error {
+  padding: 12px;
+  border-radius: 6px;
+  border: 1px solid var(--danger);
+  background: rgba(255, 92, 92, 0.1);
+  color: var(--danger);
+  font-size: 13px;
+}
+
+.object-editor { display: flex; flex-direction: column; gap: 6px; }
+.object-fields {
+  padding: 8px 0 0 10px;
+  border-left: 2px solid var(--border);
+}
+.object-fields[hidden] { display: none; }
diff --git a/frontend/index.html b/frontend/index.html
index 46de10e..fa5bc50 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -132,12 +132,16 @@ 

Elements

+ + +
+

           
         
diff --git a/frontend/js/api.js b/frontend/js/api.js
index eb39148..b3e8205 100644
--- a/frontend/js/api.js
+++ b/frontend/js/api.js
@@ -11,7 +11,9 @@ async function request(path, options = {}) {
       const body = await response.json();
       detail = body.detail || detail;
     } catch { /* ignore */ }
-    throw new Error(detail);
+    const error = new Error(typeof detail === 'string' ? detail : JSON.stringify(detail));
+    error.status = response.status;
+    throw error;
   }
   if (response.status === 204) return null;
   return response.json();
@@ -34,13 +36,21 @@ export const api = {
     method: 'POST', body: JSON.stringify(project),
   }),
 
+  validate: (project) => request('/api/validate', {
+    method: 'POST', body: JSON.stringify(project),
+  }),
+
+  preview: (project, accent = 'red') => request('/api/preview', {
+    method: 'POST', body: JSON.stringify({ project, accent }),
+  }),
+
   getSettings: () => request('/api/settings'),
   saveSettings: (settings) => request('/api/settings', {
     method: 'POST', body: JSON.stringify(settings),
   }),
 
   haStatus: () => request('/api/ha/status'),
-  haEntities: (domain) => request(`/api/ha/entities${domain ? `?domain=${domain}` : ''}`),
+  haEntities: (domain) => request(`/api/ha/entities${domain ? `?domain=${encodeURIComponent(domain)}` : ''}`),
   haPush: (payload) => request('/api/ha/push', {
     method: 'POST', body: JSON.stringify(payload),
   }),
diff --git a/frontend/js/app.js b/frontend/js/app.js
index 1233639..fce1daf 100644
--- a/frontend/js/app.js
+++ b/frontend/js/app.js
@@ -16,6 +16,7 @@ import { initCode, scheduleGenerate } from './code.js';
 import {
   openProjects, openSettings, openExport, openPush, toast, closeModal,
 } from './modals.js';
+import { glyphFor } from './theme.js';
 
 let saveTimer = null;
 
@@ -128,13 +129,7 @@ function buildPalette() {
 }
 
 function iconFor(spec) {
-  const map = {
-    text: 'T', multiline: '≡', icon: '★', icon_sequence: '⋯', qrcode: '▦',
-    dlimg: '🖼', line: '─', rectangle: '▭', rectangle_pattern: '▦',
-    polygon: '⬟', circle: '○', ellipse: '⬭', arc: '◔',
-    progress_bar: '▰', plot: '📈', debug_grid: '▩',
-  };
-  return map[spec.type] || '◆';
+  return glyphFor(spec.type, spec);
 }
 
 // ---------------------------------------------------------------------------
@@ -288,10 +283,14 @@ function wireKeyboard() {
 
     if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
       event.preventDefault();
-      saveProject();
+      saveProject({ manual: true });
       return;
     }
 
+    // Plain-key shortcuts only: Cmd/Ctrl/Alt combos (Cmd+C, Ctrl+Arrow, …)
+    // belong to the browser and the OS.
+    if (event.metaKey || event.ctrlKey || event.altKey) return;
+
     const step = event.shiftKey ? 10 : 1;
     if (event.key === 'ArrowLeft') { event.preventDefault(); nudgeSelected(-step, 0); scheduleGenerate(); }
     if (event.key === 'ArrowRight') { event.preventDefault(); nudgeSelected(step, 0); scheduleGenerate(); }
@@ -328,25 +327,64 @@ function updateSaveState() {
   el.classList.toggle('is-saved', !state.dirty);
 }
 
+// Every edit bumps `revision`. A save only clears the dirty flag when nothing
+// changed while it was in flight, and only one save runs at a time — two
+// concurrent first saves (debounce + Cmd+S) used to create two projects.
+let revision = 0;
+let saving = null;
+let saveAgain = false;
+let saveErrorShown = false;
+
 function scheduleSave() {
+  revision += 1;
   clearTimeout(saveTimer);
-  saveTimer = setTimeout(saveProject, 1500);
+  saveTimer = setTimeout(() => saveProject(), 1500);
 }
 
-async function saveProject() {
+async function saveProject({ manual = false } = {}) {
   if (!state.project) return;
-  try {
-    // Strip editor-only preview metadata (keys prefixed with "__").
-    const payload = JSON.parse(JSON.stringify(state.project, (key, value) =>
-      key.startsWith('__') ? undefined : value));
-    const saved = state.project.id
-      ? await api.updateProject(state.project.id, payload)
-      : await api.createProject(payload);
-    state.project.id = saved.id;
-    state.dirty = false;
-    updateSaveState();
-  } catch {
-    // Autosave failures are non-fatal; the user can still export.
+  clearTimeout(saveTimer);
+  if (saving) {
+    saveAgain = true;
+    return saving;
+  }
+  const project = state.project;
+  const savedRevision = revision;
+  saving = (async () => {
+    try {
+      // Strip editor-only preview metadata (keys prefixed with "__").
+      const payload = JSON.parse(JSON.stringify(project, (key, value) =>
+        key.startsWith('__') ? undefined : value));
+      let saved;
+      if (project.id) {
+        try {
+          saved = await api.updateProject(project.id, payload);
+        } catch (error) {
+          // Deleted meanwhile (e.g. from another tab): save it as a new one.
+          if (error.status !== 404) throw error;
+          saved = await api.createProject({ ...payload, id: null });
+        }
+      } else {
+        saved = await api.createProject(payload);
+      }
+      if (state.project !== project) return; // another project was loaded
+      project.id = saved.id;
+      if (revision === savedRevision) state.dirty = false;
+      updateSaveState();
+      saveErrorShown = false;
+      if (manual) toast('Project saved', 'success');
+    } catch (error) {
+      // Autosave errors are shown once per failure streak, manual ones always.
+      if (manual || !saveErrorShown) toast(`Save failed: ${error.message}`, 'error');
+      if (!manual) saveErrorShown = true;
+    } finally {
+      saving = null;
+    }
+  })();
+  await saving;
+  if (saveAgain) {
+    saveAgain = false;
+    if (state.dirty) await saveProject();
   }
 }
 
@@ -359,6 +397,9 @@ function loadProject(project) {
     background: project.background || 'white',
     nodes: project.nodes || [],
     variables: project.variables || [],
+    rotate: [0, 90, 180, 270].includes(Number(project.rotate)) ? Number(project.rotate) : 0,
+    dither: [0, 1, 2].includes(Number(project.dither)) ? Number(project.dither) : 2,
+    ttl: Number.isInteger(Number(project.ttl)) && Number(project.ttl) >= 0 ? Number(project.ttl) : 60,
   };
   state.selection = null;
   state.dirty = false;
diff --git a/frontend/js/canvas.js b/frontend/js/canvas.js
index 21709e5..456f2d4 100644
--- a/frontend/js/canvas.js
+++ b/frontend/js/canvas.js
@@ -1,7 +1,8 @@
 // Canvas: renders the display, handles drag & drop, selection, move and resize.
 
 import {
-  state, setState, createElement, createGroup, findNode, removeNode, emit, typeSpec,
+  state, setState, createElement, createGroup, findNode, findParent, removeNode, emit, typeSpec,
+  uid,
 } from './state.js';
 import { boundsOf, applyBounds, snapValue, buildPreviewContext, resolveProps } from './geometry.js';
 import { renderElementContent, elementLabel } from './renderer.js';
@@ -189,32 +190,39 @@ function isDynamic(value) {
  * Home Assistant owns its final position.
  */
 function computePreviewLayout(project) {
-  const baseCtx = buildPreviewContext(project);
+  layoutGroups(project.nodes, buildPreviewContext(project));
+}
 
-  for (const node of project.nodes) {
+// Groups can be nested; every loop level pins its own variable to 0 and sees
+// the variables of the loops around it.
+function layoutGroups(nodes, parentCtx) {
+  for (const node of nodes) {
     if (node.kind !== 'group') continue;
 
-    const repeatVar = node.repeat?.var || 'i';
-    const ctx = { ...baseCtx, [repeatVar]: 0 };
-
-    // `{% set %}` statements run before the loop body.
-    for (const statement of node.repeat?.pre || []) {
-      const match = String(statement).match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+?)\s*$/);
-      if (!match) continue;
-      const [, name, expr] = match;
-      try {
-        const parsed = JSON.parse(expr);
-        if (Array.isArray(parsed)) ctx[name] = parsed;
-      } catch { /* not a literal list */ }
+    const ctx = { ...parentCtx };
+    if (node.repeat?.enabled !== false) {
+      const repeatVar = String(node.repeat?.var || 'i').trim() || 'i';
+      ctx[repeatVar] = 0;
+
+      // `{% set %}` statements run before the loop body.
+      for (const statement of node.repeat?.pre || []) {
+        const match = String(statement).match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+?)\s*$/);
+        if (!match) continue;
+        const [, name, expr] = match;
+        try {
+          const parsed = JSON.parse(expr);
+          if (Array.isArray(parsed)) ctx[name] = parsed;
+        } catch { /* not a literal list */ }
+      }
     }
 
-    node.__resolvedGroup = true;
     for (const child of node.children || []) {
       if (child.kind === 'group') continue;
       child.__resolvedProps = resolveProps(child.props, ctx);
       child.__display = null;
       child.__approximate = childHasUnresolvedGeometry(child);
     }
+    layoutGroups(node.children || [], ctx);
   }
 }
 
@@ -239,7 +247,7 @@ function buildElementNode(node, index) {
     wrapper.classList.add('is-group');
     wrapper.innerHTML = `
       
-
⟳ ${escapeHtml(node.name || 'group')} × ${node.repeat?.count ?? 1}
+
⟳ ${escapeHtml(node.name || 'group')} × ${escapeHtml(String(node.repeat?.count ?? 1))}
`; } else { const box = node.__display || boundsOf(node); @@ -279,7 +287,7 @@ function buildElementNode(node, index) { function groupBounds(group) { const boxes = (group.children || []) - .map((child) => child.__display || boundsOf(child)) + .map((child) => (child.kind === 'group' ? groupBounds(child) : child.__display || boundsOf(child))) .filter(Boolean); if (!boxes.length) return { x: 0, y: 0, w: 40, h: 40 }; const minX = Math.min(...boxes.map((b) => b.x)); @@ -316,9 +324,7 @@ function startDrag(event, node, mode) { function collectTargets(node) { if (node.kind === 'group') { - return (node.children || []).filter( - (child) => child.kind !== 'group' && !child.__approximate, - ); + return (node.children || []).flatMap((child) => collectTargets(child)); } return node.__approximate ? [] : [node]; } @@ -395,13 +401,23 @@ export function deleteSelected() { export function duplicateSelected() { const node = state.selection ? findNode(state.selection) : null; if (!node) return; - const clone = JSON.parse(JSON.stringify(node)); - clone.id = `${node.id}-copy-${Math.random().toString(36).slice(2, 6)}`; + const clone = JSON.parse(JSON.stringify(node, (key, value) => + (key.startsWith('__') ? undefined : value))); + // Every node in the copy needs a fresh id, children included: duplicate ids + // make selection and the layer tree act on the wrong node. + const reId = (item) => { + item.id = uid(item.kind === 'group' ? 'grp' : 'el'); + (item.children || []).forEach(reId); + }; + reId(clone); if (clone.kind !== 'group') { const box = boundsOf(node); applyBounds(clone, { x: box.x + 10, y: box.y + 10, w: box.w, h: box.h }); } - state.project.nodes.push(clone); + // The copy goes right above the original, in the same parent. + const location = findParent(node.id); + const siblings = location ? location.list : state.project.nodes; + siblings.splice(location ? location.index + 1 : siblings.length, 0, clone); setState({ selection: clone.id, dirty: true }, 'structure'); render(); } diff --git a/frontend/js/code.js b/frontend/js/code.js index ec1f14c..d97093a 100644 --- a/frontend/js/code.js +++ b/frontend/js/code.js @@ -2,12 +2,21 @@ import { state, setState } from './state.js'; import { api } from './api.js'; +import { openPixelPreview } from './modals.js'; let output; +let warningsEl; +let statusEl; +let validateBtn; let debounceTimer = null; export function initCode() { output = document.getElementById('code-output'); + warningsEl = document.getElementById('code-warnings'); + statusEl = document.getElementById('code-status'); + validateBtn = document.getElementById('btn-validate'); + const renderBtn = document.getElementById('btn-render'); + if (renderBtn) renderBtn.addEventListener('click', () => openPixelPreview()); document.querySelectorAll('[data-code]').forEach((button) => { button.addEventListener('click', () => { @@ -34,6 +43,8 @@ export function initCode() { }); }); + if (validateBtn) validateBtn.addEventListener('click', runValidation); + paint(); } @@ -53,8 +64,101 @@ async function generate() { } } +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +function renderStatus(node) { + if (!statusEl) return; + if (!node) { + statusEl.hidden = true; + statusEl.replaceChildren(); + return; + } + statusEl.hidden = false; + statusEl.replaceChildren(...(Array.isArray(node) ? node : [node])); +} + +function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text != null) node.textContent = text; + return node; +} + +function showPending() { + renderStatus(el('span', 'status-head', '⏳ Rendering the template…')); + if (statusEl) statusEl.className = 'code-status is-pending'; +} + +function showResult(result) { + const source = result.source === 'ha' + ? 'rendered by Home Assistant' + : 'rendered locally (Home Assistant not reachable)'; + + if (result.ok) { + if (statusEl) statusEl.className = 'code-status is-ok'; + renderStatus([ + el('div', 'status-head', `✓ Valid — ${result.count} element(s), ${source}`), + result.haError ? el('div', 'status-detail', `Home Assistant fallback: ${result.haError}`) : null, + ].filter(Boolean)); + return; + } + + const stageLabel = { + render: 'the template failed to render', + parse: 'the rendered output is not valid JSON', + elements: 'the payload contains invalid elements', + }[result.stage] || 'validation failed'; + + if (statusEl) statusEl.className = 'code-status is-error'; + const nodes = [el('div', 'status-head', `✕ Invalid — ${stageLabel}`)]; + + // For element problems `error` is just a copy of the first issue, so only + // show it when it carries something extra (the render/parse stages). + const hasIssues = result.issues && result.issues.length; + if (result.error && !hasIssues) nodes.push(el('div', 'status-detail', result.error)); + + if (hasIssues) { + const list = el('ul'); + for (const issue of result.issues) list.appendChild(el('li', null, issue)); + nodes.push(list); + } + + if (result.excerpt) nodes.push(el('pre', null, result.excerpt)); + + renderStatus(nodes); +} + +async function runValidation() { + if (!state.project || !validateBtn) return; + validateBtn.disabled = true; + const label = validateBtn.textContent; + validateBtn.textContent = 'Checking…'; + showPending(); + try { + const result = await api.validate(state.project); + showResult(result); + } catch (error) { + if (statusEl) statusEl.className = 'code-status is-error'; + renderStatus(el('div', 'status-head', `✕ ${error.message}`)); + } finally { + validateBtn.disabled = false; + validateBtn.textContent = label; + } +} + function paint() { if (!output) return; + if (warningsEl) { + const warnings = state.generated.warnings || []; + warningsEl.hidden = !warnings.length; + warningsEl.replaceChildren(...warnings.map((text) => { + const line = document.createElement('div'); + line.textContent = `⚠ ${text}`; + return line; + })); + } const mode = state.codeMode; if (mode === 'payload') { output.textContent = JSON.stringify(state.generated.payload || [], null, 2); diff --git a/frontend/js/geometry.js b/frontend/js/geometry.js index 202fbde..cde32a7 100644 --- a/frontend/js/geometry.js +++ b/frontend/js/geometry.js @@ -1,10 +1,7 @@ // Derives a bounding box (in display pixels) for every element type. // The box is what the canvas uses for hit-testing, dragging and resizing. -import { typeSpec } from './state.js'; - -const CHAR_WIDTH = 0.58; // monospace-ish advance per font-size unit -const LINE_HEIGHT = 1.25; +import { state, typeSpec } from './state.js'; /** * Builds the variable context used to preview Jinja expressions. @@ -106,16 +103,72 @@ function num(value, fallback = 0) { return Number.isFinite(parsed) ? parsed : fallback; } +// drawcustom accepts positions as a percentage of the canvas, e.g. "50%". +function coord(value, extent) { + if (typeof value === 'string' && /^\s*-?\d+(\.\d+)?%\s*$/.test(value)) { + return (parseFloat(value) / 100) * extent; + } + return num(value); +} +const cx = (value) => coord(value, state.project?.width || 0); +const cy = (value) => coord(value, state.project?.height || 0); + +// Pillow text anchors: horizontal l/m/r, vertical a/t (top), m, s/b/d (bottom). +// Returns the fraction of the box that lies left of / above the anchor point. +function anchorFactors(anchor) { + const a = String(anchor || '').toLowerCase(); + const fx = { l: 0, m: 0.5, r: 1 }[a[0]] ?? 0; + const fy = { a: 0, t: 0, m: 0.5, s: 1, b: 1, d: 1 }[a[1]] ?? 0; + return [fx, fy]; +} + +// Top-left corner of a box of size w×h whose anchor sits at (x, y). +// `anchorH` is the height the vertical anchor refers to: the whole box, or a +// single line for `multiline`, where HA anchors every line on its own. +function anchored(p, x, y, w, h, anchorH = h) { + const [fx, fy] = anchorFactors(p.anchor); + return { x: x - w * fx, y: y - anchorH * fy, w, h }; +} + +// Inverse of `anchored`: where the anchor point of `box` lies. +function anchorPoint(p, box, anchorH = box.h) { + const [fx, fy] = anchorFactors(p.anchor); + return { x: Math.round(box.x + box.w * fx), y: Math.round(box.y + anchorH * fy) }; +} + +// Lines of a text element; `multiline` drops newlines and splits on the +// delimiter, exactly like the HA image generator. +function textLines(p, g) { + const text = String(p[g.text] ?? ''); + if (g.split) { + const delimiter = String(p[g.split] ?? ''); + const flat = text.replace(/\n/g, ''); + return delimiter ? flat.split(delimiter) : [flat]; + } + return text.split('\n'); +} + function propsOf(node) { return node.__resolvedProps || node.props || {}; } -function textSize(text, size) { +// Text metrics come from the element's geometry block in schema.py, so the +// editor and the generator describe text sizing in exactly one place. The +// fallbacks only apply to element types that carry no metrics. +function charWidthOf(g) { + return num(g?.char_width, 0.58); +} + +function lineHeightOf(g) { + return num(g?.line_height, 1.25); +} + +function textSize(text, size, g) { const lines = String(text ?? '').split('\n'); const longest = lines.reduce((max, line) => Math.max(max, line.length), 0); return { - w: Math.max(4, longest * size * CHAR_WIDTH), - h: Math.max(4, lines.length * size * LINE_HEIGHT), + w: Math.max(4, longest * size * charWidthOf(g)), + h: Math.max(4, lines.length * size * lineHeightOf(g)), }; } @@ -131,8 +184,8 @@ export function boundsOf(node) { switch (g.kind) { case 'point': { - const x = num(p[g.x]); - const y = num(p[g.y]); + const x = cx(p[g.x]); + const y = cy(p[g.y]); if (g.radius) { const r = num(p[g.radius], 10); @@ -157,32 +210,30 @@ export function boundsOf(node) { const total = count * size + (count - 1) * spacing; const horizontal = (p.direction || 'right') === 'right' || (p.direction || 'right') === 'left'; return horizontal - ? { x, y, w: total, h: size } - : { x, y, w: size, h: total }; + ? anchored(p, x, y, total, size) + : anchored(p, x, y, size, total); } if (g.square) { const size = num(p.size, 24); - return { x, y, w: size, h: size }; + return anchored(p, x, y, size, size); } // text / multiline const size = num(p.size, 20); - let text = String(p[g.text] ?? ''); - if (g.split && p[g.split]) { - text = text.split(String(p[g.split])).join('\n'); - } - const measured = textSize(text, size); - if (g.line_step && text.includes('\n')) { - const lines = text.split('\n').length; - measured.h = (lines - 1) * num(p[g.line_step], 20) + size * LINE_HEIGHT; + const lines = textLines(p, g); + const measured = textSize(lines.join('\n'), size, g); + if (g.line_step) { + const lineH = size * lineHeightOf(g); + measured.h = (lines.length - 1) * num(p[g.line_step], 20) + lineH; + return anchored(p, x, y, measured.w, measured.h, lineH); } - return { x, y, w: measured.w, h: measured.h }; + return anchored(p, x, y, measured.w, measured.h); } case 'box': { - const x1 = num(p[g.x_start]); - const y1 = num(p[g.y_start]); - const x2 = num(p[g.x_end]); - const y2 = num(p[g.y_end]); + const x1 = cx(p[g.x_start]); + const y1 = cy(p[g.y_start]); + const x2 = cx(p[g.x_end]); + const y2 = cy(p[g.y_end]); const left = Math.min(x1, x2); const top = Math.min(y1, y2); const w = Math.abs(x2 - x1); @@ -194,8 +245,8 @@ export function boundsOf(node) { } case 'pattern': { - const xStart = num(p[g.x_start]); - const yStart = num(p[g.y_start]); + const xStart = cx(p[g.x_start]); + const yStart = cy(p[g.y_start]); const xSize = num(p[g.x_size], 10); const ySize = num(p[g.y_size], 10); const xOffset = num(p[g.x_offset]); @@ -273,25 +324,24 @@ export function applyBounds(node, box, mode = 'move') { const total = horizontal ? box.w : box.h; const size = Math.max(1, Math.round((total - (count - 1) * (num(p.spacing) || 0)) / count)); p[g.size] = size; - p[g.x] = Math.round(box.x); - p[g.y] = Math.round(box.y); + Object.assign(p, keyed(g, anchorPoint(p, box))); return; } if (g.square) { const size = Math.max(1, Math.round(Math.max(box.w, box.h))); p[g.size] = size; - p[g.x] = Math.round(box.x); - p[g.y] = Math.round(box.y); + Object.assign(p, keyed(g, anchorPoint(p, { ...box, w: size, h: size }))); return; } // text / multiline + const lines = textLines(p, g).length; + const step = g.line_step ? num(p[g.line_step], 20) : 0; if (mode === 'resize') { - const lines = String(p[g.text] ?? '').split('\n').length; - const size = Math.max(1, Math.round(box.h / (lines * LINE_HEIGHT))); - p[g.size] = size; + const textH = g.line_step ? box.h - (lines - 1) * step : box.h / lines; + p[g.size] = Math.max(1, Math.round(textH / lineHeightOf(g))); } - p[g.x] = Math.round(box.x); - p[g.y] = Math.round(box.y); + const lineH = g.line_step ? num(p.size, 20) * lineHeightOf(g) : box.h; + Object.assign(p, keyed(g, anchorPoint(p, box, lineH))); return; } @@ -332,6 +382,10 @@ export function applyBounds(node, box, mode = 'move') { } } +function keyed(g, point) { + return { [g.x]: point.x, [g.y]: point.y }; +} + export function snapValue(value, enabled, step = 2) { if (!enabled) return Math.round(value); return Math.round(value / step) * step; diff --git a/frontend/js/inspector.js b/frontend/js/inspector.js index 6c0b790..64157e0 100644 --- a/frontend/js/inspector.js +++ b/frontend/js/inspector.js @@ -2,6 +2,7 @@ import { state, setState, findNode, typeSpec, emit } from './state.js'; import { render } from './canvas.js'; +import { resolveSwatch as sharedSwatch } from './theme.js'; let container; let emptyState; @@ -72,6 +73,7 @@ function buildHeader(node, spec) { const dup = document.createElement('button'); dup.className = 'btn-icon'; dup.title = 'Duplicate'; + dup.setAttribute('aria-label', dup.title); dup.textContent = '⧉'; dup.addEventListener('click', async () => { const { duplicateSelected } = await import('./canvas.js'); @@ -81,6 +83,7 @@ function buildHeader(node, spec) { const del = document.createElement('button'); del.className = 'btn-icon'; del.title = 'Delete'; + del.setAttribute('aria-label', del.title); del.textContent = '🗑'; del.addEventListener('click', async () => { const { deleteSelected } = await import('./canvas.js'); @@ -118,9 +121,19 @@ function buildGroupInspector(node) { node.repeat.var = value || 'i'; })); - section.appendChild(buildSimpleField('Iterations', 'number', node.repeat?.count ?? 1, (value) => { - node.repeat.count = Math.max(1, parseInt(value, 10) || 1); - })); + // A whole number (0 allowed) or a Jinja expression such as + // `forecast | length` / `{{ n }}`; the generator handles both. + const iterations = buildSimpleField('Iterations', 'text', node.repeat?.count ?? 1, (value) => { + const text = String(value).trim(); + if (text === '') node.repeat.count = 1; + else if (/^\d+$/.test(text)) node.repeat.count = Number(text); + else node.repeat.count = text; + }); + const help = document.createElement('div'); + help.className = 'field-help'; + help.innerHTML = 'A number, or a Jinja expression such as forecast | length.'; + iterations.appendChild(help); + section.appendChild(iterations); section.appendChild(buildSimpleField('Enabled', 'bool', node.repeat?.enabled !== false, (value) => { node.repeat.enabled = value; @@ -234,6 +247,9 @@ function buildField(node, field) { case 'plotdata': wrap.appendChild(plotDataInput(value, field, setValue)); break; + case 'object': + wrap.appendChild(objectInput(node, field)); + break; default: wrap.appendChild(textInput(value, field, setValue)); } @@ -348,13 +364,7 @@ function colorInput(value, field, setValue) { } function resolveSwatch(value) { - const map = { - black: '#000', white: '#fff', red: '#e02020', yellow: '#e8c000', accent: '#e02020', - half_black: '#808080', half_white: '#c0c0c0', half_red: '#f09090', - half_yellow: '#f4e090', half_accent: '#f09090', - }; - const key = String(value).toLowerCase(); - return map[key] || key; + return sharedSwatch(value); } function selectInput(value, field, setValue) { @@ -546,6 +556,51 @@ function plotDataInput(value, field, setValue) { return wrap; } +// A nested option object (plot axes / legends): `null` = off, otherwise a dict +// of sub-properties edited with the regular field editors. +function objectInput(node, field) { + const box = document.createElement('div'); + box.className = 'object-editor'; + + const row = document.createElement('label'); + row.className = 'checkbox-row'; + const toggle = document.createElement('input'); + toggle.type = 'checkbox'; + toggle.checked = isPlainObject(node.props[field.key]); + row.append(toggle, document.createTextNode(`Show ${field.label.toLowerCase()}`)); + box.appendChild(row); + + const body = document.createElement('div'); + body.className = 'object-fields'; + box.appendChild(body); + + const paint = () => { + body.innerHTML = ''; + const value = node.props[field.key]; + body.hidden = !isPlainObject(value); + if (!isPlainObject(value)) return; + // The sub-editors write straight into this dict through a stand-in node. + const proxy = { props: value }; + for (const sub of field.fields || []) { + if (!(sub.key in value)) value[sub.key] = sub.default ?? null; + body.appendChild(buildField(proxy, sub)); + } + }; + + toggle.addEventListener('change', () => { + node.props[field.key] = toggle.checked ? {} : null; + paint(); + markDirty(); + }); + + paint(); + return box; +} + +function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + function markDirty() { setState({ dirty: true }, 'change'); emit('inspector'); diff --git a/frontend/js/layers.js b/frontend/js/layers.js index 4234af2..3f5e5ac 100644 --- a/frontend/js/layers.js +++ b/frontend/js/layers.js @@ -3,6 +3,7 @@ import { state, setState, findNode, findParent, removeNode, typeSpec } from './state.js'; import { render } from './canvas.js'; import { elementLabel } from './renderer.js'; +import { glyphFor } from './theme.js'; let container; @@ -61,6 +62,7 @@ function buildLayer(node, siblings) { const vis = document.createElement('button'); vis.className = 'btn-icon'; vis.title = 'Toggle visibility'; + vis.setAttribute('aria-label', vis.title); vis.textContent = node.props?.visible === false ? '🚫' : '👁'; vis.addEventListener('click', (event) => { event.stopPropagation(); @@ -75,6 +77,7 @@ function buildLayer(node, siblings) { const up = document.createElement('button'); up.className = 'btn-icon'; up.title = 'Bring forward'; + up.setAttribute('aria-label', up.title); up.textContent = '▲'; up.addEventListener('click', (event) => { event.stopPropagation(); @@ -84,6 +87,7 @@ function buildLayer(node, siblings) { const down = document.createElement('button'); down.className = 'btn-icon'; down.title = 'Send backward'; + down.setAttribute('aria-label', down.title); down.textContent = '▼'; down.addEventListener('click', (event) => { event.stopPropagation(); @@ -93,6 +97,7 @@ function buildLayer(node, siblings) { const del = document.createElement('button'); del.className = 'btn-icon'; del.title = 'Delete'; + del.setAttribute('aria-label', del.title); del.textContent = '✕'; del.addEventListener('click', (event) => { event.stopPropagation(); @@ -134,9 +139,8 @@ function buildLayer(node, siblings) { } row.addEventListener('click', () => { - setState({ selection: node.id }); - render(); - renderLayers(); + // 'structure' so the Inspector picks up the new selection too. + setState({ selection: node.id }, 'structure'); }); wrap.appendChild(row); @@ -175,7 +179,7 @@ function reparent(nodeId, targetId) { const target = targetId ? findNode(targetId) : null; if (target) { if (target.kind !== 'group') return; - // Reject cycles. + // Reject cycles (a group dropped into itself or its descendants). let cursor = target; while (cursor) { if (cursor.id === nodeId) return; @@ -197,12 +201,5 @@ function reparent(nodeId, targetId) { } function iconFor(type) { - const spec = typeSpec(type); - const map = { - text: 'T', multiline: '≡', icon: '★', icon_sequence: '⋯', qrcode: '▦', - dlimg: '🖼', line: '─', rectangle: '▭', rectangle_pattern: '▦', - polygon: '⬟', circle: '○', ellipse: '⬭', arc: '◔', - progress_bar: '▰', plot: '📈', debug_grid: '▩', - }; - return map[type] || spec?.label?.[0] || '?'; + return glyphFor(type, typeSpec(type)); } diff --git a/frontend/js/modals.js b/frontend/js/modals.js index 10664f9..7550327 100644 --- a/frontend/js/modals.js +++ b/frontend/js/modals.js @@ -111,8 +111,11 @@ export async function openProjects(onLoad) { const del = document.createElement('button'); del.className = 'btn-icon'; del.textContent = '🗑'; + del.title = `Delete ${project.name}`; + del.setAttribute('aria-label', `Delete project ${project.name}`); del.addEventListener('click', async (event) => { event.stopPropagation(); + if (!window.confirm(`Delete project "${project.name}"? This cannot be undone.`)) return; try { await api.deleteProject(project.id); item.remove(); @@ -168,7 +171,7 @@ export async function openSettings(onSaved) {
Long-lived access token
-
Leave empty to keep the existing token. Can also be provided via the HA_TOKEN environment variable.
+
Leave empty to keep the existing token. Changing the URL requires entering the token again. Can also be provided via the HA_TOKEN environment variable (used only with HA_URL).
Target device ID
@@ -226,6 +229,11 @@ export async function openSettings(onSaved) { if (token) payload.haToken = token; const saved = await api.saveSettings(payload); setState({ settings: saved }); + if (saved.tokenCleared) { + // The token is bound to the URL it was entered for (see storage.py). + toast('Home Assistant URL changed — please enter the token for the new URL', 'error'); + document.getElementById('set-token').placeholder = 'paste token'; + } return saved; } } @@ -285,6 +293,134 @@ export function openExport() { }); } +// --------------------------------------------------------------------------- +// Pixel-accurate preview +// --------------------------------------------------------------------------- + +/** + * Renders the project with the real drawing code and shows the PNG. + * + * This is the honest counterpart to the canvas: same fonts, colours, + * coordinates and Pillow calls as the integration, so what is shown here is + * what the tag will draw. + */ +export async function openPixelPreview() { + let result = null; + let failure = null; + + try { + result = await api.preview(state.project, state.previewAccent || 'red'); + } catch (error) { + failure = error.message; + } + + let zoom = 2; + let showGrid = false; + + openModal('Pixel preview', (body) => { + if (failure) { + body.innerHTML = `
${escapeHtml(failure)}
`; + return; + } + + const wrap = document.createElement('div'); + wrap.className = 'preview-wrap'; + + const toolbar = document.createElement('div'); + toolbar.className = 'preview-toolbar'; + toolbar.innerHTML = ` + ${result.width}×${result.height} · ${result.elements} element(s) + + + + + `; + wrap.appendChild(toolbar); + + const stage = document.createElement('div'); + stage.className = 'preview-stage'; + + const img = document.createElement('img'); + img.className = 'preview-image'; + img.src = result.image; + img.alt = 'Rendered preview of the display'; + stage.appendChild(img); + wrap.appendChild(stage); + + const applyZoom = () => { + img.style.width = `${result.width * zoom}px`; + img.style.height = `${result.height * zoom}px`; + img.style.imageRendering = zoom >= 2 ? 'pixelated' : 'auto'; + }; + applyZoom(); + + const zoomInput = toolbar.querySelector('#preview-zoom'); + zoomInput.addEventListener('input', () => { + zoom = Math.min(8, Math.max(1, Number(zoomInput.value) || 1)); + applyZoom(); + }); + + const accentSelect = toolbar.querySelector('#preview-accent'); + accentSelect.value = state.previewAccent || 'red'; + accentSelect.addEventListener('change', async () => { + setState({ previewAccent: accentSelect.value }); + closeModal(); + await openPixelPreview(); + }); + + toolbar.querySelector('#preview-checker').addEventListener('click', (event) => { + showGrid = !showGrid; + stage.classList.toggle('is-checkered', showGrid); + event.target.classList.toggle('is-active', showGrid); + }); + + body.appendChild(wrap); + + if (result.errors.length) { + body.appendChild(previewIssues('✕ Some elements could not be drawn', result.errors, 'is-error')); + } + if (result.notes.length) { + body.appendChild(previewIssues('ℹ Not shown in this preview', result.notes, 'is-note')); + } + }, (foot) => { + const download = document.createElement('button'); + download.className = 'btn btn-ghost'; + download.textContent = 'Download PNG'; + download.addEventListener('click', () => { + if (!result) return; + const link = document.createElement('a'); + link.href = result.image; + link.download = `${(state.project.name || 'epaper').replace(/\s+/g, '-').toLowerCase()}-preview.png`; + link.click(); + }); + foot.appendChild(download); + }); +} + +function previewIssues(title, items, className) { + const box = document.createElement('div'); + box.className = `preview-issues ${className}`; + const heading = document.createElement('div'); + heading.className = 'status-head'; + heading.textContent = title; + box.appendChild(heading); + const list = document.createElement('ul'); + for (const item of items) { + const li = document.createElement('li'); + li.textContent = item; + list.appendChild(li); + } + box.appendChild(list); + return box; +} + // --------------------------------------------------------------------------- // Push to display // --------------------------------------------------------------------------- @@ -300,11 +436,41 @@ export function openPush() {
Service
+
+
+
Rotate
+ +
+
+
Dither
+ +
+
+
TTL (s)
+ +
+
-

The generated Jinja template is sent as the payload parameter.

+

The generated Jinja template is sent as the payload parameter. + Rotate, dither and TTL are saved with the project.

`; + // The body is not in the document yet, so query it directly. + const saveOptions = () => { + const ttl = Math.round(Number(body.querySelector('#push-ttl').value)); + state.project.rotate = Number(body.querySelector('#push-rotate').value); + state.project.dither = Number(body.querySelector('#push-dither').value); + state.project.ttl = Number.isFinite(ttl) ? Math.min(86400, Math.max(0, ttl)) : 60; + setState({ dirty: true }, 'change'); + }; + ['#push-rotate', '#push-dither', '#push-ttl'].forEach((selector) => { + body.querySelector(selector).addEventListener('change', saveOptions); + }); }, (foot) => { const send = document.createElement('button'); send.className = 'btn btn-primary'; @@ -313,6 +479,8 @@ export function openPush() { send.disabled = true; send.textContent = 'Sending…'; try { + // Make sure a TTL that is still being typed is applied. + document.getElementById('push-ttl').dispatchEvent(new Event('change')); const result = await api.haPush({ project: state.project, deviceId: document.getElementById('push-device').value.trim(), diff --git a/frontend/js/renderer.js b/frontend/js/renderer.js index 9ef1b0d..a80d560 100644 --- a/frontend/js/renderer.js +++ b/frontend/js/renderer.js @@ -2,19 +2,7 @@ import { boundsOf } from './geometry.js'; import { typeSpec } from './state.js'; - -const COLOR_MAP = { - black: '#000000', - white: '#ffffff', - red: '#e02020', - yellow: '#e8c000', - accent: '#e02020', - half_black: '#808080', - half_white: '#c0c0c0', - half_red: '#f09090', - half_yellow: '#f4e090', - half_accent: '#f09090', -}; +import { resolveColor } from './theme.js'; // The MDI webfont glyphs are optional; when it is missing we fall back to a // simple placeholder so the canvas still communicates the layout. @@ -49,19 +37,6 @@ function iconHtml(name, color, size) { border:1px dashed currentColor;border-radius:3px;box-sizing:border-box;overflow:hidden;">${safe.slice(0, 3)}`; } -export function resolveColor(value, fallback = '#000000') { - if (!value) return fallback; - const key = String(value).toLowerCase(); - if (COLOR_MAP[key]) return COLOR_MAP[key]; - if (/^#[0-9a-f]{3}$/i.test(key) || /^#[0-9a-f]{6}$/i.test(key)) return key; - if (key === 'b') return '#000000'; - if (key === 'w') return '#ffffff'; - if (key === 'r') return '#e02020'; - if (key === 'y') return '#e8c000'; - if (key === 'a') return '#e02020'; - return fallback; -} - function escapeHtml(text) { return String(text ?? '') .replace(/&/g, '&') @@ -96,19 +71,24 @@ export function renderElementContent(node) { const color = resolveColor(p.color, '#000'); const size = Number(p.size) || 20; const weight = p.stroke_width ? 'bold' : 'normal'; - return `
${escapeHtml(text)}
`; + const align = ['left', 'center', 'right'].includes(p.align) ? p.align : 'left'; + return `
${escapeHtml(text)}
`; } case 'multiline': { - const delimiter = p.delimiter || '|'; - let text = String(p.value ?? ''); + // Same split as HA: newlines are dropped, then the delimiter splits. + const delimiter = String(p.delimiter ?? ''); + let text = String(p.value ?? '').replace(/\n/g, ''); if (p.parse_colors) text = stripColorMarkup(text); - const lines = text.split(delimiter); + const lines = delimiter ? text.split(delimiter) : [text]; const color = resolveColor(p.color, '#000'); const size = Number(p.size) || 20; const offset = Number(p.offset_y) || 20; + // Every line is anchored on its own, so a middle/right anchor centres or + // right-aligns each line individually. + const align = { m: 'center', r: 'right' }[String(p.anchor || 'lm')[0]] || 'left'; const html = lines - .map((line, index) => `
${escapeHtml(line)}
`) + .map((line, index) => `
${escapeHtml(line)}
`) .join(''); return `
${html}
`; } @@ -184,6 +164,7 @@ export function renderElementContent(node) { const ySize = Number(p.y_size) || 10; const xOffset = Number(p.x_offset) || 0; const yOffset = Number(p.y_offset) || 0; + const radius = Number(p.radius) || 0; const totalW = xRepeat * xSize + (xRepeat - 1) * xOffset; const totalH = yRepeat * ySize + (yRepeat - 1) * yOffset; let rects = ''; @@ -191,7 +172,7 @@ export function renderElementContent(node) { for (let col = 0; col < xRepeat; col += 1) { const x = col * (xSize + xOffset); const y = row * (ySize + yOffset); - rects += ``; + rects += ``; } } return `${rects}`; diff --git a/frontend/js/state.js b/frontend/js/state.js index db00383..cb0e4ce 100644 --- a/frontend/js/state.js +++ b/frontend/js/state.js @@ -11,6 +11,8 @@ export const state = { snap: true, preview: false, codeMode: 'template', + // Accent color used by the pixel-accurate preview, matching the tag. + previewAccent: 'red', generated: { template: '', yaml: '', payload: [] }, dirty: false, settings: { haUrl: '', deviceId: '', service: '', hasToken: false }, @@ -45,6 +47,10 @@ export function createProject(name = 'Untitled') { background: 'white', nodes: [], variables: [], + // drawcustom service options (see "Send to display"). + rotate: 0, + dither: 2, + ttl: 60, }; } diff --git a/frontend/js/theme.js b/frontend/js/theme.js new file mode 100644 index 0000000..1b58c91 --- /dev/null +++ b/frontend/js/theme.js @@ -0,0 +1,84 @@ +// Shared presentation constants used by the canvas renderer, the palette, the +// layer list and the inspector. Keeping them in one place means a new color or +// element type only has to be described once. + +/** + * ESL color names to hex. `accent` maps to red because the canvas has to pick + * one; the real tag decides at render time. + */ +export const COLOR_MAP = { + black: '#000000', + white: '#ffffff', + red: '#e02020', + yellow: '#e8c000', + accent: '#e02020', + half_black: '#808080', + half_white: '#c0c0c0', + half_red: '#f09090', + half_yellow: '#f4e090', + half_accent: '#f09090', +}; + +/** Single letter shortcuts accepted by drawcustom. */ +const COLOR_SHORTCUTS = { + b: '#000000', + w: '#ffffff', + r: '#e02020', + y: '#e8c000', + a: '#e02020', +}; + +const HEX_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i; + +/** + * Resolves any drawcustom color value to a CSS color. + * Unknown names fall back so a typo still renders something visible. + */ +export function resolveColor(value, fallback = '#000000') { + if (!value) return fallback; + const key = String(value).toLowerCase(); + if (COLOR_MAP[key]) return COLOR_MAP[key]; + if (COLOR_SHORTCUTS[key]) return COLOR_SHORTCUTS[key]; + if (HEX_RE.test(key)) return key; + return fallback; +} + +/** + * Resolves a color for a small swatch. Unlike `resolveColor` it returns an + * unknown value unchanged so the swatch can still show whatever the user typed. + */ +export function resolveSwatch(value) { + if (!value) return ''; + const key = String(value).toLowerCase(); + if (COLOR_MAP[key]) return COLOR_MAP[key]; + if (COLOR_SHORTCUTS[key]) return COLOR_SHORTCUTS[key]; + return key; +} + +/** + * Compact glyphs for the palette and the layer list. These are plain characters + * rather than MDI icons so they keep working when the webfont is unavailable. + */ +export const TYPE_GLYPHS = { + text: 'T', + multiline: '≡', + icon: '★', + icon_sequence: '⋯', + qrcode: '▦', + dlimg: '🖼', + line: '─', + rectangle: '▭', + rectangle_pattern: '▦', + polygon: '⬟', + circle: '○', + ellipse: '⬭', + arc: '◔', + progress_bar: '▰', + plot: '📈', + debug_grid: '▩', +}; + +/** Glyph for an element type, with a per-type fallback. */ +export function glyphFor(type, spec = null) { + return TYPE_GLYPHS[type] || spec?.label?.[0] || '◆'; +} diff --git a/frontend/js/variables.js b/frontend/js/variables.js index cdc653f..f5f96d0 100644 --- a/frontend/js/variables.js +++ b/frontend/js/variables.js @@ -55,6 +55,7 @@ export function renderVariables() { del.className = 'btn-icon'; del.textContent = '✕'; del.title = 'Remove'; + del.setAttribute('aria-label', del.title); del.addEventListener('click', () => { variables.splice(index, 1); setState({ dirty: true }, 'structure');