Răsfoiți Sursa

Add zEPD JSON model registry: loader, codegen, default.json schema

registry.py: pure-Python drivers/*.json loader implementing the 3-tier
recursive merge (default.json -> named model -> optional YAML overlay)
from claude/DESCRIPTOR_REGISTRY_PLAN.md, with unknown-key warnings.

codegen.py: pure (dict) -> C++ str emitter producing an inline const
zepd::EngineModel plus its backing EpdCommand/palette arrays - no
ESPHome/cg/CORE dependency, so it's callable from both display.py's
to_code() and standalone host tooling.

default.json: the full schema, generated programmatically from the
legacy panel_102.h (GxEPD2_102, a small mono panel) rather than
hand-transcribed, to rule out byte-transcription error. Verified
byte-for-byte identical to the legacy C++ array data via codegen
round-trip before committing.
ZiB 2 săptămâni în urmă
părinte
comite
a179e39d8d

+ 136 - 0
esphome-zepd/components/zepd/codegen.py

@@ -0,0 +1,136 @@
+"""Merged model dict -> C++ EngineModel codegen.
+
+Plain `(dict) -> str`, no ESPHome/`cg`/`CORE` dependency (see
+claude/DESCRIPTOR_REGISTRY_PLAN.md) - callable from display.py's to_code()
+at ESPHome compile time, and from standalone host tooling
+(check_descriptors.py and friends) outside any ESPHome build.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+_LINE_ORDERS = {"SEQUENTIAL": "zepd::LineOrder::SEQUENTIAL", "PAIRED_EDGES": "zepd::LineOrder::PAIRED_EDGES"}
+
+
+def _hex_bytes(s: str) -> list[int]:
+    s = s or ""
+    if len(s) % 2 != 0:
+        raise ValueError(f"odd-length hex byte string: {s!r}")
+    return [int(s[i : i + 2], 16) for i in range(0, len(s), 2)]
+
+
+def _byte_array_literal(data: list[int]) -> str:
+    return "{" + ", ".join(f"0x{b:02X}" for b in data) + "}"
+
+
+def _emit_sequence(out: list[str], prefix: str, seq: list[dict]) -> tuple[str, int]:
+    """Emits one EpdCommand table (and its per-entry data byte arrays) as
+    `prefix`-named globals; returns (array name, length)."""
+    entries = []
+    for i, c in enumerate(seq):
+        data = _hex_bytes(c.get("data", ""))
+        data_name = "nullptr"
+        if data:
+            data_name = f"{prefix}_{i}_DATA"
+            out.append(f"inline const uint8_t {data_name}[] = {_byte_array_literal(data)};")
+        cmd = _hex_bytes(c["cmd"])[0]
+        entries.append(
+            "    {{0x{cmd:02X}, {data_name}, {data_len}, {before}, {after}, {wait}}},".format(
+                cmd=cmd,
+                data_name=data_name,
+                data_len=len(data),
+                before=c.get("delay_before_ms", 0),
+                after=c.get("delay_after_ms", 0),
+                wait="true" if c.get("wait_busy_after", False) else "false",
+            )
+        )
+    seq_name = f"{prefix}_SEQ"
+    if entries:
+        out.append(f"inline const zepd::EpdCommand {seq_name}[] = {{")
+        out.extend(entries)
+        out.append("};")
+    else:
+        out.append(f"inline const zepd::EpdCommand *{seq_name} = nullptr;")
+    return seq_name, len(entries)
+
+
+def generate_cpp(model: dict[str, Any], var_name: str) -> str:
+    """Generates one `inline const zepd::EngineModel {var_name} = {...};`
+    plus its backing command/palette arrays, as a single translation unit's
+    worth of text (no #pragma once / includes - the caller wraps this)."""
+    out: list[str] = []
+    prefix = var_name.upper()
+
+    init_name, init_len = _emit_sequence(out, f"{prefix}_INIT", model["init_sequence"])
+    refresh_name, refresh_len = _emit_sequence(out, f"{prefix}_REFRESH", model["refresh_sequence"])
+    power_off_name, power_off_len = _emit_sequence(out, f"{prefix}_POWEROFF", model.get("power_off_sequence") or [])
+    hibernate_name, hibernate_len = _emit_sequence(out, f"{prefix}_HIBERNATE", model.get("hibernate_sequence") or [])
+
+    planes = model["planes"]
+    if len(planes) > 2:
+        raise ValueError(f"{var_name}: {len(planes)} planes given, engine supports at most 2")
+    window_names: list[str] = ["nullptr", "nullptr"]
+    window_lens: list[int] = [0, 0]
+    plane_write_cmd: list[int] = [0, 0]
+    plane_write_cmd_full: list[int] = [0, 0]
+    plane_invert: list[bool] = [False, False]
+    window_per_plane = False
+    plane_width = 0
+    base_window = planes[0].get("window_sequence")
+    for i, p in enumerate(planes):
+        window_seq = p.get("window_sequence") or (base_window if i == 0 else None)
+        if window_seq is not None:
+            if i > 0 and p.get("window_sequence") is not None:
+                window_per_plane = True
+            window_names[i], window_lens[i] = _emit_sequence(out, f"{prefix}_WINDOW{i}", window_seq)
+        plane_write_cmd[i] = _hex_bytes(p["plane_write_cmd"])[0]
+        plane_write_cmd_full[i] = _hex_bytes(p.get("plane_write_cmd_for_full_refresh", "00"))[0]
+        plane_invert[i] = bool(p.get("plane_invert", False))
+        if p.get("width") is not None:
+            plane_width = p["width"]
+
+    palette_name = "nullptr"
+    palette_len = 0
+    palette_entries = model.get("palette") or []
+    if palette_entries:
+        palette_name = f"{prefix}_PALETTE"
+        out.append(f"inline const zepd::EpdPaletteEntry {palette_name}[] = {{")
+        for e in palette_entries:
+            r, g, b = _hex_bytes(e["rgb"])
+            code = _hex_bytes(e["code"])[0]
+            out.append(f"    {{0x{r:02X}, 0x{g:02X}, 0x{b:02X}, 0x{code:02X}}},")
+        out.append("};")
+        palette_len = len(palette_entries)
+
+    out.append(f"inline const zepd::EngineModel {var_name} = {{")
+    out.append(f"    .width = {model['width']},")
+    out.append(f"    .height = {model['height']},")
+    out.append(f"    .busy_active_level = {'true' if model['busy_active_level'] else 'false'},")
+    out.append(f"    .busy_timeout_ms = {model['busy_timeout_ms']},")
+    out.append(f"    .init_sequence = {init_name},")
+    out.append(f"    .init_sequence_len = {init_len},")
+    out.append(f"    .window_sequence = {{{window_names[0]}, {window_names[1]}}},")
+    out.append(f"    .window_sequence_len = {{{window_lens[0]}, {window_lens[1]}}},")
+    out.append(f"    .window_per_plane = {'true' if window_per_plane else 'false'},")
+    out.append(f"    .plane_count = {len(planes)},")
+    out.append(f"    .plane_write_cmd = {{0x{plane_write_cmd[0]:02X}, 0x{plane_write_cmd[1]:02X}}},")
+    out.append(
+        "    .plane_write_cmd_for_full_refresh = "
+        f"{{0x{plane_write_cmd_full[0]:02X}, 0x{plane_write_cmd_full[1]:02X}}},"
+    )
+    out.append(f"    .plane_invert = {{{'true' if plane_invert[0] else 'false'}, {'true' if plane_invert[1] else 'false'}}},")
+    out.append(f"    .plane_width = {plane_width},")
+    out.append(f"    .line_order = {_LINE_ORDERS[model.get('line_order', 'SEQUENTIAL')]},")
+    out.append(f"    .bits_per_pixel = {model.get('bits_per_pixel', 1)},")
+    out.append(f"    .palette = {palette_name},")
+    out.append(f"    .palette_len = {palette_len},")
+    out.append(f"    .blank_code = 0x{model.get('blank_code', 0):02X},")
+    out.append(f"    .refresh_sequence = {refresh_name},")
+    out.append(f"    .refresh_sequence_len = {refresh_len},")
+    out.append(f"    .power_off_sequence = {power_off_name},")
+    out.append(f"    .power_off_sequence_len = {power_off_len},")
+    out.append(f"    .hibernate_sequence = {hibernate_name},")
+    out.append(f"    .hibernate_sequence_len = {hibernate_len},")
+    out.append("};")
+    return "\n".join(out) + "\n"

+ 0 - 0
esphome-zepd/components/zepd/drivers/.gitkeep


+ 9 - 0
esphome-zepd/components/zepd/drivers/README.md

@@ -0,0 +1,9 @@
+# drivers/
+
+One `<model>.json` per panel. `default.json` is both the full schema (every
+key present) and a real, working small mono panel (GxEPD2_102) - loading
+order is `default.json` -> `<model>.json` -> optional YAML overlay, each
+step a recursive dict merge (named keys merge; lists replace whole).
+
+See `../../../../claude/DESCRIPTOR_REGISTRY_PLAN.md` for the full design,
+`../registry.py` for the loader, `../codegen.py` for the dict -> C++ step.

+ 295 - 0
esphome-zepd/components/zepd/drivers/default.json

@@ -0,0 +1,295 @@
+{
+  "oem_part_numbers": [
+    "GDEW0102T4"
+  ],
+  "source_driver": "GxEPD2_102",
+  "source_url": "",
+  "verified": false,
+  "controller": "UC8175",
+  "fmt": "mono",
+  "width": 80,
+  "height": 128,
+  "busy_active_level": false,
+  "busy_timeout_ms": 15000,
+  "init_sequence": [
+    {
+      "cmd": "D2",
+      "data": "3F",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": false
+    },
+    {
+      "cmd": "00",
+      "data": "4F",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": false
+    },
+    {
+      "cmd": "01",
+      "data": "03002B2B",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": false
+    },
+    {
+      "cmd": "06",
+      "data": "3F",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": false
+    },
+    {
+      "cmd": "2A",
+      "data": "0000",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": false
+    },
+    {
+      "cmd": "30",
+      "data": "13",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": false
+    },
+    {
+      "cmd": "50",
+      "data": "57",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": false
+    },
+    {
+      "cmd": "60",
+      "data": "22",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": false
+    },
+    {
+      "cmd": "61",
+      "data": "5080",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": false
+    },
+    {
+      "cmd": "82",
+      "data": "12",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": false
+    },
+    {
+      "cmd": "E3",
+      "data": "33",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": false
+    },
+    {
+      "cmd": "00",
+      "data": "6F",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": false
+    },
+    {
+      "cmd": "30",
+      "data": "05",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": false
+    },
+    {
+      "cmd": "50",
+      "data": "F2",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": false
+    },
+    {
+      "cmd": "82",
+      "data": "00",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": false
+    },
+    {
+      "cmd": "23",
+      "data": "600101000001800F00000001000000000000000000000000000000000000000000000000000000000000",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": false
+    },
+    {
+      "cmd": "24",
+      "data": "900101000001400F00000001000000000000000000000000000000000000000000000000000000000000",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": false
+    },
+    {
+      "cmd": "04",
+      "data": "",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": true
+    }
+  ],
+  "refresh_sequence": [
+    {
+      "cmd": "92",
+      "data": "",
+      "delay_before_ms": 0,
+      "delay_after_ms": 1,
+      "wait_busy_after": false
+    },
+    {
+      "cmd": "D2",
+      "data": "3F",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": false
+    },
+    {
+      "cmd": "00",
+      "data": "4F",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": false
+    },
+    {
+      "cmd": "01",
+      "data": "03002B2B",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": false
+    },
+    {
+      "cmd": "06",
+      "data": "3F",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": false
+    },
+    {
+      "cmd": "2A",
+      "data": "0000",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": false
+    },
+    {
+      "cmd": "30",
+      "data": "13",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": false
+    },
+    {
+      "cmd": "50",
+      "data": "57",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": false
+    },
+    {
+      "cmd": "60",
+      "data": "22",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": false
+    },
+    {
+      "cmd": "61",
+      "data": "5080",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": false
+    },
+    {
+      "cmd": "82",
+      "data": "12",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": false
+    },
+    {
+      "cmd": "E3",
+      "data": "33",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": false
+    },
+    {
+      "cmd": "23",
+      "data": "605A5A000001000000000000000000000000000000000000000000000000000000000000000000000000",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": false
+    },
+    {
+      "cmd": "24",
+      "data": "905A5A000001000000000000000000000000000000000000000000000000000000000000000000000000",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": false
+    },
+    {
+      "cmd": "12",
+      "data": "",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": true
+    }
+  ],
+  "power_off_sequence": [
+    {
+      "cmd": "02",
+      "data": "",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": true
+    }
+  ],
+  "hibernate_sequence": [
+    {
+      "cmd": "07",
+      "data": "A5",
+      "delay_before_ms": 0,
+      "delay_after_ms": 0,
+      "wait_busy_after": false
+    }
+  ],
+  "line_order": "SEQUENTIAL",
+  "bits_per_pixel": 1,
+  "palette": [],
+  "blank_code": 0,
+  "planes": [
+    {
+      "window_sequence": [
+        {
+          "cmd": "91",
+          "data": "",
+          "delay_before_ms": 0,
+          "delay_after_ms": 0,
+          "wait_busy_after": false
+        },
+        {
+          "cmd": "90",
+          "data": "004F007F00",
+          "delay_before_ms": 0,
+          "delay_after_ms": 0,
+          "wait_busy_after": false
+        }
+      ],
+      "plane_write_cmd": "13",
+      "plane_write_cmd_for_full_refresh": "10",
+      "plane_invert": false,
+      "width": null
+    }
+  ]
+}

+ 72 - 0
esphome-zepd/components/zepd/registry.py

@@ -0,0 +1,72 @@
+"""The zEPD model registry: loads drivers/*.json and merges each named model
+over default.json.
+
+Pure Python, no ESPHome dependency - see claude/DESCRIPTOR_REGISTRY_PLAN.md.
+Both display.py (at ESPHome compile time) and standalone host tooling
+(check_descriptors.py and friends) import this module directly.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+DRIVERS_DIR = Path(__file__).parent / "drivers"
+
+
+def _merge(base: Any, overlay: Any) -> Any:
+    """Recursive dict merge. Named dict keys merge recursively; anything
+    anonymous/positional (list elements) replaces whole. A key present in
+    `overlay` but not in `base` is dropped silently - the accumulated
+    `base` (built up from default.json first) is the implicit schema."""
+    if isinstance(base, dict) and isinstance(overlay, dict):
+        out = dict(base)
+        for key, value in overlay.items():
+            if key in out:
+                out[key] = _merge(out[key], value)
+            # else: unknown key, dropped - see unknown_keys() for a warning pass.
+        return out
+    return overlay
+
+
+def unknown_keys(base: dict, overlay: dict, prefix: str = "") -> list[str]:
+    """Dotted-path keys present in `overlay` that don't exist anywhere in
+    `base` - a likely typo, surfaced as a warning (not an error) by callers."""
+    found: list[str] = []
+    for key, value in overlay.items():
+        path = f"{prefix}.{key}" if prefix else key
+        if key not in base:
+            found.append(path)
+        elif isinstance(base.get(key), dict) and isinstance(value, dict):
+            found.extend(unknown_keys(base[key], value, path))
+    return found
+
+
+def load_default(drivers_dir: Path = DRIVERS_DIR) -> dict:
+    with open(drivers_dir / "default.json") as f:
+        return json.load(f)
+
+
+def load_model(name: str, drivers_dir: Path = DRIVERS_DIR, overlay: dict | None = None) -> dict:
+    """Load one model: default.json, then drivers/<name>.json (skipped when
+    name == "default"), then an optional YAML-provided overlay dict."""
+    merged = load_default(drivers_dir)
+    if name != "default":
+        path = drivers_dir / f"{name}.json"
+        with open(path) as f:
+            model_json = json.load(f)
+        unk = unknown_keys(merged, model_json)
+        if unk:
+            print(f"warning: {path}: unknown key(s), possible typo: {', '.join(unk)}")
+        merged = _merge(merged, model_json)
+    if overlay:
+        unk = unknown_keys(merged, overlay)
+        if unk:
+            print(f"warning: model_overlay for {name!r}: unknown key(s), possible typo: {', '.join(unk)}")
+        merged = _merge(merged, overlay)
+    return merged
+
+
+def list_models(drivers_dir: Path = DRIVERS_DIR) -> list[str]:
+    return sorted(p.stem for p in drivers_dir.glob("*.json") if p.stem != "default")