#pragma once // Prototype: emit a candidate panel_*.h EpdPanelDescriptor by *observing* a // real vendor GxEPD2 driver run, instead of hand-transcribing its C++. This // closes the hand-derivation gap for the straight-line parts of a descriptor // (init / window / refresh command sequences + the plane-write command) by // phase-segmenting the captured event stream at the public-API boundary - no // edits to the vendor source. // // Staging (the trick that separates the phases without instrumenting the // vendor's private methods): // writeScreenBuffer() -> lazy _InitDisplay() + a plane-write burst (clear). // Everything before that burst is the INIT sequence; // the command right before it is the PLANE-WRITE cmd. // writeImage(pattern) -> init already done, so this is just the WINDOW set // (commands before the pattern burst) + plane write. // refresh() -> re-sets the window then triggers; dropping the // duplicate window command leaves the REFRESH seq. // // Out of scope here (comes from the diag Pattern A/B passes, not one capture): // bits_per_pixel / palette / plane_invert / line_order. Those are emitted // as TODO placeholders so the diff against the hand file is about segmentation. #include #include #include #include #include #include #include #include #include #include "spy/Arduino.h" #include "spy/SPI.h" #include "trace.h" namespace emit { // A data burst at least this long is a pixel-plane write, not config data. constexpr size_t PLANE_BURST_MIN = 256; struct Cmd { uint8_t cmd; std::vector data; bool wait_busy = false; unsigned long delay_after = 0; }; // Expose GxEPD2_EPD's protected busy level without parsing (same trick as // run_harness.h's Peek). template struct Peek : Driver { using Driver::Driver; int busy_level() const { return this->_busy_level; } }; // Group a slice of the event stream into EpdCommand-shaped records. Events // before the first SPI *command* (the reset pulse + its delays) are skipped - // the engine owns the hardware reset, so it never belongs in a descriptor. inline std::vector group(size_t lo, size_t hi) { std::vector cmds; bool started = false; for (size_t i = lo; i < hi; i++) { const auto &e = Trace::events[i]; switch (e.kind) { case EventKind::SPI_TRANSFER: if (e.is_command) { cmds.push_back(Cmd{e.byte}); started = true; } else if (started) { cmds.back().data.push_back(e.byte); } break; case EventKind::DELAY: if (started) cmds.back().delay_after += e.ms; break; case EventKind::DIGITAL_READ: // the only reads are busy-pin polls if (started) { cmds.back().wait_busy = true; // The delay(1) GxEPD2_EPD::_waitWhileBusy() runs inside its poll // loop lands right before this read and is part of the busy-wait, // not an inter-command delay - our engine owns busy-waiting, so // fold it in rather than emit it. (This also absorbs a vendor's own // tiny settle delay right before a refresh trigger, e.g. 290c's // _refresh delay(1); immaterial, the panel asserts busy regardless.) cmds.back().delay_after = 0; } break; default: break; // DIGITAL_WRITE = reset/CS/DC toggles: engine's job, not data } } return cmds; } // Index of the first command whose data burst marks a plane write, or np // (cmds.size()) if there's none in this slice. inline size_t plane_index(const std::vector &cmds) { for (size_t i = 0; i < cmds.size(); i++) if (cmds[i].data.size() >= PLANE_BURST_MIN) return i; return cmds.size(); } inline void print_data_array(const char *macro, const char *suffix, const std::vector &d) { printf("inline const uint8_t %s_%s[] = {", macro, suffix); for (size_t i = 0; i < d.size(); i++) printf("%s0x%02X", i ? ", " : "", d[i]); printf("};\n"); } // Print one EpdCommand{} initializer line, with the optional fields only when // they're non-default (matches the hand files' style). inline void print_cmd(const char *macro, const char *suffix, const Cmd &c) { if (c.data.empty() && !c.wait_busy && c.delay_after == 0) { printf(" {0x%02X, nullptr, 0},\n", c.cmd); return; } const char *dataref = c.data.empty() ? "nullptr" : nullptr; if (dataref) printf(" {0x%02X, nullptr, 0", c.cmd); else printf(" {0x%02X, %s_%s, %u", c.cmd, macro, suffix, (unsigned) c.data.size()); if (c.wait_busy || c.delay_after) printf(", /*delay_before_ms=*/0, /*delay_after_ms=*/%lu, /*wait_busy_after=*/%s", c.delay_after, c.wait_busy ? "true" : "false"); printf("},\n"); } // First data byte of the burst written right after command `op`, or -1. inline int burst_byte_after(const std::vector &cmds, uint8_t op) { for (const auto &c : cmds) if (c.cmd == op && c.data.size() >= PLANE_BURST_MIN) return c.data[0]; return -1; } // The full data burst written right after command `op`, or empty. inline std::vector burst_data_after(const std::vector &cmds, uint8_t op) { for (const auto &c : cmds) if (c.cmd == op && c.data.size() >= PLANE_BURST_MIN) return c.data; return {}; } // Detects the two-arg writeScreenBuffer(black_value, color_value) that colour // (plane_count > 1) drivers have and mono drivers don't. template struct has_wsb2 : std::false_type {}; template struct has_wsb2().writeScreenBuffer(uint8_t{}, uint8_t{}))>> : std::true_type {}; struct PlaneInvert { bool inv0 = false, inv1 = false; bool plane1_valid = false; bool is_1bpp = false; // white fill => an all-bits-same wire byte (not packed) int w0_white = -1, w1_red = -1; }; // A packed panel's depth / palette / blank code, resolved by observation for // the panel families the emitter knows how to probe (currently 4-colour, via // emit_palette_4c.h). `emit_descriptor_impl` calls a resolver of this shape // only for a packed panel (the plane-invert probe said white fill isn't an // all-bits wire byte); a null or unresolved result keeps the old TODO fields, // so an unknown packed family still emits a reviewable placeholder rather than // a wrong palette. struct PackedResolved { bool resolved = false; // false => emit TODO placeholders int bits_per_pixel = 2; // 2 (4-colour) or 4 (7-colour) const char *palette_expr = "nullptr"; // e.g. "GXEPD2_PALETTE_4C" const char *palette_len_expr = "0"; // e.g. "GXEPD2_PALETTE_4C_LEN" int blank_code = 0; // per-pixel clear code (observed white) int color_count = -1; // actual distinct colours probed; -1 => assume 1<1 bit/pixel on the wire internally, with no // colour wrapper to drive a la emit_palette_4c.h/7c.h - e.g. GxEPD2_583's // _send8pixel. Only two codes are ever observed (black_code, blank_code= // white code), so the emitter prints a local 2-entry palette array instead // of referencing a shared GXEPD2_PALETTE_* constant. bool two_code_bw = false; int black_code = -1; }; using PackedResolverFn = std::function; // A generous, class-aware default for busy_timeout_ms. The timeout is only a // diagnostic ceiling - the engine hard-aborts a busy-wait that runs past it and // logs a failure, where GxEPD2 itself just keeps polling - so erring long costs // nothing on a healthy panel and the exact number is meant to be dev-tuned. The // vendor full_refresh_time is a soft estimate, so we take 2x it, then clamp up // to a per-depth floor so a panel that under-reports (or whose colour pass is // genuinely slow) still gets room: mono/3-colour fail fast, 4-colour and // 7-colour (ACeP) get progressively more headroom. inline uint32_t default_busy_timeout_ms(unsigned full_refresh_time, int bits_per_pixel) { uint32_t headroom = static_cast(full_refresh_time) * 2; uint32_t floor = bits_per_pixel >= 4 ? 60000u : bits_per_pixel == 2 ? 45000u : 15000u; return headroom > floor ? headroom : floor; } // plane_invert by capture (see probe_plane_invert.h for the rationale): drive // known full-screen fills and read each plane's wire byte, reconciling against // the engine's fixed buffer convention (plane0 bit=white, plane1 bit=red). A // white-fill plane0 wire that isn't 0x00/0xFF means a packed (>1bpp) panel, // where plane_invert is unused and bits_per_pixel/palette come from the diag // pass instead. template PlaneInvert probe_plane_invert_values(uint8_t p0cmd, uint8_t p1cmd, size_t plane_count) { constexpr int16_t CS = 10, DC = 11, RST = 12, BUSY = 13; Trace::set_dc_pin(DC); Peek d(CS, DC, RST, BUSY); Trace::last_busy_release_value = d.busy_level() == HIGH ? LOW : HIGH; d.selectSPI(SPI, SPISettings()); d.init(0); // plane_bytes sized for the 1bpp hypothesis - same assumption // emit_descriptor_impl's own main capture makes (see its `black`/`color` // buffers). A genuinely packed driver still re-packs this buffer through // its real plane-write path (e.g. GxEPD2_583's _send8pixel), so the wire // byte it reports is the true packed code, not a false 1bpp reading. const uint32_t plane_bytes = (static_cast(Driver::WIDTH) * Driver::HEIGHT) / 8; auto *fillbuf = new uint8_t[plane_bytes]; PlaneInvert r; // Drive the SAME call path (writeImage) that produced p0cmd/p1cmd, not // writeScreenBuffer: some drivers (e.g. GxEPD2_270, "current" buffer 0x13 // vs image-plane 0x15) use a different command for the screen-buffer clear // than for writeImage's own plane write. Probing via writeScreenBuffer then // searches the trace for a command (p0cmd) that call never emits, silently // returning "not found" (-1) and misreporting a true 1bpp panel as packed. auto cap0 = [&](uint8_t value) -> int { memset(fillbuf, value, plane_bytes); Trace::start_event_record(); d.writeImage(fillbuf, fillbuf, 0, 0, Driver::WIDTH, Driver::HEIGHT); Trace::stop_event_record(); return burst_byte_after(group(0, Trace::events.size()), p0cmd); }; r.w0_white = cap0(0xFF); delete[] fillbuf; r.is_1bpp = (r.w0_white == 0x00 || r.w0_white == 0xFF); r.inv0 = (r.w0_white == 0x00); // engine plane0 bit=white; white wire 0x00 => invert if (plane_count > 1) { if constexpr (has_wsb2::value) { Trace::start_event_record(); d.writeScreenBuffer((uint8_t) 0xFF, (uint8_t) 0x00); // all-red fill Trace::stop_event_record(); r.w1_red = burst_byte_after(group(0, Trace::events.size()), p1cmd); r.plane1_valid = (r.w1_red == 0x00 || r.w1_red == 0xFF); r.inv1 = (r.w1_red == 0x00); // engine plane1 bit=red; red wire 0x00 => invert } } return r; } // Generic packed resolver for a driver with NO colour wrapper to drive (unlike // emit_palette_4c.h/7c.h, which observe a GxEPD2_4C/7C wrapper's named-colour // fills) - the gap GxEPD2_583 (UC8159c) exposed: it's wrapped GxEPD2_BW // (binary), but `_send8pixel` expands each 1bpp buffer byte into a wider wire // format using only two of its codes. Drives the plain Driver's own // writeImage(black,color,...) - the SAME call the main capture already makes // - with a uniform all-white (0xFF) and all-black (0x00) 1bpp buffer. If both // wire bursts are internally uniform (one repeated byte - true for a per-bit // code lookup with no cross-pixel dependency) and the byte count divides the // pixel count evenly, that ratio directly gives bits_per_pixel; the MSB field // of each uniform byte gives that colour's code. A driver whose packing // depends on more than one bit at a time (dithering, error diffusion) fails // the uniformity check and stays an honest TODO rather than a wrong guess. template PackedResolverFn make_resolver_bw_packed(uint8_t p0cmd) { return [p0cmd](uint8_t /*clear_fill*/, const PlaneInvert & /*pv*/) -> PackedResolved { PackedResolved r; auto fail = [&](const char *why) { r.resolved = false; r.note = why; return r; }; constexpr int16_t CS = 10, DC = 11, RST = 12, BUSY = 13; Trace::set_dc_pin(DC); Peek d(CS, DC, RST, BUSY); Trace::last_busy_release_value = d.busy_level() == HIGH ? LOW : HIGH; d.selectSPI(SPI, SPISettings()); d.init(0); const uint16_t W = Driver::WIDTH, H = Driver::HEIGHT; const uint32_t plane_bytes = (uint32_t) W * H / 8; // 1bpp-sized source buffer auto *buf = new uint8_t[plane_bytes]; auto burst_for = [&](uint8_t value) -> std::vector { memset(buf, value, plane_bytes); Trace::start_event_record(); d.writeImage(buf, buf, 0, 0, W, H); Trace::stop_event_record(); return burst_data_after(group(0, Trace::events.size()), p0cmd); }; const std::vector white = burst_for(0xFF); // engine convention: bit=1 => white const std::vector black = burst_for(0x00); delete[] buf; if (white.empty() || black.empty()) return fail("bw-packed probe: no plane-write burst found"); if (white.size() != black.size()) return fail("bw-packed probe: white/black burst lengths differ"); const uint32_t total_pixels = (uint32_t) W * H; if (white.size() == 0 || total_pixels % white.size() != 0) return fail("bw-packed probe: pixel count not a multiple of the wire-byte count"); const int ppb = (int) (total_pixels / white.size()); // pixels per wire byte if (ppb <= 0 || ppb > 8 || (8 % ppb) != 0) return fail("bw-packed probe: implied pixels/byte doesn't divide 8 evenly"); const int bpp = 8 / ppb; const uint8_t wbyte = white[0], bbyte = black[0]; for (uint8_t v : white) if (v != wbyte) return fail("bw-packed probe: white-fill wire byte not uniform (packing depends on more than one bit)"); for (uint8_t v : black) if (v != bbyte) return fail("bw-packed probe: black-fill wire byte not uniform (packing depends on more than one bit)"); if (wbyte == bbyte) return fail("bw-packed probe: white and black fills produced the same wire byte"); const uint8_t mask = (uint8_t) ((1u << bpp) - 1); const uint8_t white_code = (wbyte >> (bpp * (ppb - 1))) & mask; // MSB field (every field equal, uniform byte) const uint8_t black_code = (bbyte >> (bpp * (ppb - 1))) & mask; r.resolved = true; r.bits_per_pixel = bpp; r.color_count = 2; r.two_code_bw = true; r.blank_code = white_code; r.black_code = black_code; char note[96]; snprintf(note, sizeof(note), "derived from raw 1bpp fills, no colour wrapper: black=0x%02X white=0x%02X, %d px/wire-byte", black_code, white_code, ppb); r.note = note; return r; }; } // Plane-sized (>= PLANE_BURST_MIN) burst commands one call makes, from a // fresh instance with an untraced writeScreenBuffer() first (same trick // probe_plane_invert_values uses) to absorb the lazy _InitDisplay()/ // _initial_write clear. Shared by probe_full_refresh_extra_write_cmd below so // it can tell a genuinely NEW burst from one the plain single-bitmap // writeImage() already makes on its own - a 2-plane (3-colour) driver's // single-bitmap writeImage(bitmap,...) forwards to writeImage(black, NULL, // ...), which bursts to BOTH its real plane_write_cmd[0] (real data) AND // plane_write_cmd[1] (a NULL-color blank-fill burst, same size) every time, // with no full-refresh involved at all - diffing against just plane_write_cmd // [0] would misread that second plane's ordinary burst as a full-refresh-only // extra write. template std::set plane_burst_cmds(Call call) { constexpr int16_t CS = 10, DC = 11, RST = 12, BUSY = 13; Trace::set_dc_pin(DC); Peek d(CS, DC, RST, BUSY); Trace::last_busy_release_value = d.busy_level() == HIGH ? LOW : HIGH; d.selectSPI(SPI, SPISettings()); d.init(0); d.writeScreenBuffer(); // untraced: absorb lazy init + the _initial_write clear const uint32_t plane_bytes = (uint32_t) Driver::WIDTH * Driver::HEIGHT / 8; auto *buf = new uint8_t[plane_bytes]; memset(buf, 0xA5, plane_bytes); Trace::start_event_record(); call(d, buf); Trace::stop_event_record(); delete[] buf; std::set cmds; for (const auto &c : group(0, Trace::events.size())) if (c.data.size() >= PLANE_BURST_MIN) cmds.insert(c.cmd); return cmds; } // Detects a controller whose writeImageForFullRefresh() diverges from plain // writeImage() - e.g. SSD1680/SSD1619-family controllers write the same // bitmap to a "previous RAM" bank (a different command) before the normal // "current RAM" write plane_write_cmd[plane] already captures. GxEPD2_EPD's // base class defaults writeImageForFullRefresh() to call writeImage() // unchanged, so this probe generalizes across every controller without // hardcoding the known-affected families: it diffs the two calls' burst- // command sets and returns whatever writeImageForFullRefresh() adds. Returns // 0x00 (no divergence - by far the common case) or the extra command byte. template uint8_t probe_full_refresh_extra_write_cmd() { auto write_cmds = plane_burst_cmds( [](Peek &d, uint8_t *buf) { d.writeImage(buf, 0, 0, Driver::WIDTH, Driver::HEIGHT); }); auto full_cmds = plane_burst_cmds( [](Peek &d, uint8_t *buf) { d.writeImageForFullRefresh(buf, 0, 0, Driver::WIDTH, Driver::HEIGHT); }); for (uint8_t c : full_cmds) if (!write_cmds.count(c)) return c; return 0x00; } // Prints just the plane_write_cmd_for_full_refresh{} line (for splicing into // an existing hand-maintained panel_*.h, same workflow as // print_power_sequences / tools/capture.py --power). Self-contained like // print_power_sequences: derives p0cmd itself (the first plane-sized burst a // plain writeImage() produces) rather than trusting a shipped panel_*.h's // plane_write_cmd, which may have been hand-adjusted since it was captured. template void print_full_refresh_extra_write() { constexpr int16_t CS = 10, DC = 11, RST = 12, BUSY = 13; Trace::set_dc_pin(DC); Peek probe(CS, DC, RST, BUSY); Trace::last_busy_release_value = probe.busy_level() == HIGH ? LOW : HIGH; probe.selectSPI(SPI, SPISettings()); probe.init(0); probe.writeScreenBuffer(); // untraced: absorb lazy init + the _initial_write clear const uint32_t plane_bytes = (uint32_t) Driver::WIDTH * Driver::HEIGHT / 8; auto *buf = new uint8_t[plane_bytes]; memset(buf, 0xFF, plane_bytes); // 0xFF/0x00, not a filler byte: doubles as the 1bpp-vs-packed check below Trace::start_event_record(); probe.writeImage(buf, 0, 0, Driver::WIDTH, Driver::HEIGHT); Trace::stop_event_record(); delete[] buf; uint8_t p0cmd = 0x00; int w0_white = -1; for (const auto &c : group(0, Trace::events.size())) if (c.data.size() >= PLANE_BURST_MIN) { p0cmd = c.cmd; w0_white = c.data[0]; break; } // A packed (>1bpp) driver's writeImageForFullRefresh expects its own packed // bit depth, not the 1bpp-sized buffer above - probing it the same way a // 1bpp panel is probed can over/under-read and crash (seen on GDEP073E01, // 4bpp 7-colour). A 1bpp panel's white-fill wire byte is always 0x00 or // 0xFF (see probe_plane_invert_values); anything else means packed, so bail // instead of guessing - this field isn't meaningful there anyway (see // emit_descriptor_impl's pv.is_1bpp gate). if (w0_white != 0x00 && w0_white != 0xFF) { printf(" // SKIPPED: packed (>1bpp) panel - writeImageForFullRefresh's dual-write behaviour is " "specific to the 1bpp plane model, not applicable here\n"); return; } const uint8_t extra = probe_full_refresh_extra_write_cmd(); if (extra) printf(" .plane_write_cmd_for_full_refresh = {0x%02X, 0x00}, // writeImageForFullRefresh also " "re-writes this \"previous RAM\" bank before the 0x%02X write above - captured for future " "partial-refresh use, not read by this full-refresh-only engine yet\n", extra, p0cmd); else printf(" // writeImageForFullRefresh == writeImage for this controller: no " ".plane_write_cmd_for_full_refresh entry needed (0x00 default already correct)\n"); } // Captures powerOff() or hibernate() as its own standalone command sequence - // NOT folded into the refresh_seq phase-segmentation above, and NOT captured // from the same live session as each other. Every GxEPD2 driver's powerOff() // guards on `_power_is_on` (set true by the refresh() a normal update ends // with), and hibernate() calls powerOff() again internally - so capturing // both from one session would have the second call's powerOff() portion // silently emit nothing (already off from the first). Each call here gets its // own fresh instance, replayed through the exact init/clear/write/refresh // setup the main capture above uses, so `call` always runs with // _power_is_on true, matching a real update-then-power-down. template std::vector capture_power_sequence(MethodCall call) { constexpr int16_t CS = 10, DC = 11, RST = 12, BUSY = 13; Trace::set_dc_pin(DC); Peek d(CS, DC, RST, BUSY); Trace::last_busy_release_value = d.busy_level() == HIGH ? LOW : HIGH; d.selectSPI(SPI, SPISettings()); d.init(0); const uint32_t plane_bytes = (uint32_t) Driver::WIDTH * Driver::HEIGHT / 8; auto *buf = new uint8_t[plane_bytes]; memset(buf, 0xA5, plane_bytes); d.writeScreenBuffer(); d.writeImage(buf, buf, 0, 0, Driver::WIDTH, Driver::HEIGHT); d.refresh(false); delete[] buf; Trace::start_event_record(); call(d); Trace::stop_event_record(); return group(0, Trace::events.size()); } // (power_off_sequence, hibernate_sequence) for Driver, each captured via // capture_power_sequence above. hibernate_sequence is hibernate()'s tail // PAST whatever it shares with powerOff() - every driver observed so far // calls powerOff() internally then adds exactly one more command (the // deep-sleep trigger) - since power_off_sequence already has that shared // part and CycleEngine runs the two back to back (see epd_command.h); a // prefix mismatch (seen nowhere so far) falls back to the full hibernate // capture rather than guess. template std::pair, std::vector> capture_power_off_and_hibernate() { std::vector power_off_seq = capture_power_sequence([](Peek &d) { d.powerOff(); }); std::vector hibernate_full_seq = capture_power_sequence([](Peek &d) { d.hibernate(); }); std::vector hibernate_seq; if (hibernate_full_seq.size() > power_off_seq.size()) { bool prefix_matches = true; for (size_t i = 0; i < power_off_seq.size() && prefix_matches; i++) if (hibernate_full_seq[i].cmd != power_off_seq[i].cmd || hibernate_full_seq[i].data != power_off_seq[i].data) prefix_matches = false; if (prefix_matches) hibernate_seq.assign(hibernate_full_seq.begin() + power_off_seq.size(), hibernate_full_seq.end()); } if (hibernate_seq.empty()) hibernate_seq = hibernate_full_seq; return {power_off_seq, hibernate_seq}; } // Prints just the POWER_OFF/HIBERNATE arrays + EpdCommand tables (plus the // descriptor-field lines to paste) for Driver - for splicing // power_off_sequence/hibernate_sequence into an EXISTING, hand-maintained // panel_*.h (one shipped before these fields existed) without regenerating, // and losing, everything else already in it. tools/capture.py --power. template void print_power_sequences(const char *macro) { auto captured = capture_power_off_and_hibernate(); const std::vector &power_off_seq = captured.first; const std::vector &hibernate_seq = captured.second; auto emit_seq = [&](const char *seqsuffix, const std::vector &cmds) { for (size_t i = 0; i < cmds.size(); i++) if (!cmds[i].data.empty()) { char suf[64]; snprintf(suf, sizeof(suf), "%s_%zu_%02X", seqsuffix, i, cmds[i].cmd); print_data_array(macro, suf, cmds[i].data); } printf("\ninline const EpdCommand %s_%s[] = {\n", macro, seqsuffix); for (size_t i = 0; i < cmds.size(); i++) { char suf[64]; snprintf(suf, sizeof(suf), "%s_%zu_%02X", seqsuffix, i, cmds[i].cmd); print_cmd(macro, suf, cmds[i]); } printf("};\n"); }; if (!power_off_seq.empty()) { emit_seq("POWER_OFF", power_off_seq); printf("// splice into the descriptor:\n"); printf("// .power_off_sequence = %s_POWER_OFF,\n", macro); printf("// .power_off_sequence_len = sizeof(%s_POWER_OFF) / sizeof(%s_POWER_OFF[0]),\n", macro, macro); } else { printf("// powerOff(): empty capture - this driver's full refresh already leaves\n" "// _power_is_on false (its _Update_Full() powers itself down), so powerOff()\n" "// has nothing left to do. No power_off_sequence to add.\n"); } printf("\n"); if (!hibernate_seq.empty()) { emit_seq("HIBERNATE", hibernate_seq); printf("// splice into the descriptor:\n"); printf("// .hibernate_sequence = %s_HIBERNATE,\n", macro); printf("// .hibernate_sequence_len = sizeof(%s_HIBERNATE) / sizeof(%s_HIBERNATE[0]),\n", macro, macro); } else { printf("// hibernate(): empty capture (unexpected - every driver seen so far adds at\n" "// least the deep-sleep trigger when rst is wired). Review by hand.\n"); } } template void emit_descriptor_impl(const char *class_name, const char *macro, const PackedResolverFn &resolve_packed) { constexpr int16_t CS = 10, DC = 11, RST = 12, BUSY = 13; Trace::set_dc_pin(DC); Peek driver(CS, DC, RST, BUSY); const int bl = driver.busy_level(); Trace::last_busy_release_value = bl == HIGH ? LOW : HIGH; driver.selectSPI(SPI, SPISettings()); driver.init(0); const uint32_t plane_bytes = (static_cast(Driver::WIDTH) * Driver::HEIGHT) / 8; auto *black = new uint8_t[plane_bytes]; auto *color = new uint8_t[plane_bytes]; for (uint32_t i = 0; i < plane_bytes; i++) { black[i] = 0xA5; color[i] = 0xA5; } Trace::start_event_record(); const size_t s0 = 0; driver.writeScreenBuffer(); // init + clear burst const size_t s1 = Trace::events.size(); driver.writeImage(black, color, 0, 0, Driver::WIDTH, Driver::HEIGHT); // window + plane const size_t s2 = Trace::events.size(); driver.refresh(false); // window-again + trigger const size_t s3 = Trace::events.size(); Trace::stop_event_record(); delete[] black; delete[] color; // --- segment --- auto init_stage = group(s0, s1); auto image_stage = group(s1, s2); auto refresh_stage = group(s2, s3); auto cmds_equal = [](const std::vector &a, const std::vector &b) { if (a.size() != b.size()) return false; for (size_t i = 0; i < a.size(); i++) if (a[i].cmd != b[i].cmd || a[i].data != b[i].data) return false; return true; }; // Every pixel-plane burst in the image call. One => single-plane panel; // N => N planes (two colour planes sharing a region, or two physical // regions - told apart below by whether their window-sets match). std::vector bursts; for (size_t i = 0; i < image_stage.size(); i++) if (image_stage[i].data.size() >= PLANE_BURST_MIN) bursts.push_back(i); const size_t nplanes = bursts.empty() ? 1 : bursts.size(); if (getenv("EMIT_DEBUG")) { fprintf(stderr, "image_stage %zu cmds; bursts:", image_stage.size()); for (auto b : bursts) fprintf(stderr, " [%zu]=0x%02X", b, image_stage[b].cmd); fprintf(stderr, "\n"); for (size_t i = 0; i < image_stage.size(); i++) fprintf(stderr, " [%zu] 0x%02X data=%zu\n", i, image_stage[i].cmd, image_stage[i].data.size()); } // pre_block(k) = commands after burst k-1 (or the start) up to, but not // including, burst k's own plane-write command - i.e. the window set for // plane k, possibly with the lazily-run _InitDisplay in front for k==0. auto pre_block = [&](size_t k) { size_t lo = (k == 0) ? 0 : bursts[k - 1] + 1; return std::vector(image_stage.begin() + lo, image_stage.begin() + bursts[k]); }; std::vector init_seq, window0, window1; std::vector plane_cmds; bool per_plane = false; bool dual_region = false; // two physical regions (per-region select + window) int plane_width_val = 0; // decoded per-region width (dual_region only) uint32_t region0_burst = 0; // region 0's image burst length, for line-order detection if (bursts.size() >= 2) { // Multi-plane. Two observed layouts: // interleaved: [init][W0][burst0][W1][burst1] - each plane's window // sits between its burst and the previous one (dual region); // batched: [init][W][W][burst0][burst1] - all windows precede all // bursts (SSD1680 3-colour: writeImage sets the black and // red RAM windows, then bursts both planes). for (size_t k = 0; k < bursts.size(); k++) plane_cmds.push_back(image_stage[bursts[k]].cmd); const size_t n = bursts.size(); std::vector pre = pre_block(0); std::vector> windows; bool interleaved = false; for (size_t k = 1; k < n; k++) if (bursts[k] - bursts[k - 1] > 1) { interleaved = true; break; } if (interleaved) { std::vector gap0(image_stage.begin() + bursts[0] + 1, image_stage.begin() + bursts[1]); const size_t wl = gap0.size(); if (wl <= pre.size()) { windows.push_back(std::vector(pre.end() - wl, pre.end())); init_seq.assign(pre.begin(), pre.end() - wl); } else { windows.push_back({}); init_seq = pre; } for (size_t k = 1; k < n; k++) windows.push_back(std::vector(image_stage.begin() + bursts[k - 1] + 1, image_stage.begin() + bursts[k])); } else { // Batched: pre = init + n copies of the window. Peel the smallest block // that repeats n times at the tail; the remainder is init. size_t L = 0; for (size_t cand = 1; cand * n <= pre.size(); cand++) { bool ok = true; for (size_t b = 0; b < n && ok; b++) for (size_t j = 0; j < cand && ok; j++) { const Cmd &a = pre[pre.size() - cand * n + b * cand + j]; const Cmd &c = pre[pre.size() - cand + j]; if (a.cmd != c.cmd || a.data != c.data) ok = false; } if (ok) { L = cand; break; } } if (L > 0) { for (size_t b = 0; b < n; b++) windows.push_back(std::vector(pre.end() - L * (n - b), pre.end() - L * (n - b - 1))); init_seq.assign(pre.begin(), pre.end() - L * n); } else { windows.push_back({}); init_seq = pre; } } window0 = windows.empty() ? std::vector{} : windows[0]; window1 = windows.size() > 1 ? windows[1] : std::vector{}; for (size_t k = 1; k < windows.size(); k++) if (!cmds_equal(windows[0], windows[k])) per_plane = true; // per-plane windows differ => separate regions // Dual-region refinement (e.g. GDEY0579F51 / HX8717: a two-chip panel whose // "planes" are two physical half-width regions, each gated by a region- // select command before its own window + burst). The generic interleaved // path above already recovers the per-region windows and plane commands; // three things it can't are filled here. if (interleaved && per_plane) { dual_region = true; std::set wops; // region-select + window-set opcodes for (const auto &c : window0) wops.insert(c.cmd); for (const auto &c : window1) wops.insert(c.cmd); // (1) INIT. A dual-region panel runs its whole _InitDisplay during the // earlier writeScreenBuffer clear, and that init REUSES the region-select // opcode (select master/slave for each region's PSR), so the single-plane // "cut at the first window opcode" rule would cut at the very first init // command. Instead take init from the clear stage and drop only the // CONTIGUOUS trailing run of window-set commands before the first clear // burst (the both-/first-region window-sets); the select/PSR pairs earlier // in init survive because a non-window command (BTST, power-on, ...) // separates them from that trailing run. const size_t cb = plane_index(init_stage); // first clear burst size_t cut = cb; while (cut > 0 && wops.count(init_stage[cut - 1].cmd)) cut--; init_seq.assign(init_stage.begin(), init_stage.begin() + cut); // (2) plane_width: each region addresses its own half. Decode it from the // window-set command's payload - first two big-endian 16-bit fields are // x and x_end, as every window encoder observed so far lays them out. for (const auto &c : window0) if (c.data.size() >= 4) { int x = (c.data[0] << 8) | c.data[1]; int xe = (c.data[2] << 8) | c.data[3]; plane_width_val = xe - x + 1; break; } // (3) line doubling: region 0's burst length, compared in the emit stage // against plane_width*height*bpp/8 to flag PAIRED_EDGES. region0_burst = (uint32_t) image_stage[bursts[0]].data.size(); } } else { // Single plane: window = the image call's pre-burst block; init comes // from the writeScreenBuffer stage, cut at the first window-set command // (SSD16xx sets the window there too - that belongs in window_sequence). const size_t wp = plane_index(image_stage); window0.assign(image_stage.begin(), image_stage.begin() + wp); std::set wops; for (const auto &c : window0) wops.insert(c.cmd); const size_t ip = plane_index(init_stage); size_t cut = ip; for (size_t i = 0; i < ip; i++) if (wops.count(init_stage[i].cmd)) { cut = i; break; } init_seq.assign(init_stage.begin(), init_stage.begin() + cut); plane_cmds.push_back(wp < image_stage.size() ? image_stage[wp].cmd : 0x00); } // blank/clear fill byte, observed from the writeScreenBuffer clear burst. const size_t cip = plane_index(init_stage); const uint8_t clear_fill = (cip < init_stage.size() && !init_stage[cip].data.empty()) ? init_stage[cip].data[0] : 0x00; // plane_invert + 1bpp/packed detection, derived by the fill-capture probe. const uint8_t p0cmd = plane_cmds.empty() ? 0x00 : plane_cmds[0]; const uint8_t p1cmd = plane_cmds.size() > 1 ? plane_cmds[1] : 0x00; const PlaneInvert pv = probe_plane_invert_values(p0cmd, p1cmd, nplanes); // Packed-family resolution (4-colour palette etc.), run once here so both the // busy-timeout class and the packed fields below use the same answer. 1bpp // panels skip it. A specific colour-wrapper resolver (4-colour/7-colour, // passed in by emit_descriptor_4c/_7c) takes priority; absent one (the // default emit_descriptor entry - a plain, possibly still-mono- // wrapped driver), fall back to the generic no-wrapper probe, which catches // a GxEPD2_BW driver that packs internally (see make_resolver_bw_packed). // An unresolved packed panel (neither resolver recognised it) is treated as // the slow class for the timeout floor. const PackedResolverFn effective_resolver = resolve_packed ? resolve_packed : make_resolver_bw_packed(p0cmd); const PackedResolved pr = (!pv.is_1bpp && effective_resolver) ? effective_resolver(clear_fill, pv) : PackedResolved{}; const int depth_bpp = pv.is_1bpp ? 1 : (pr.resolved ? pr.bits_per_pixel : 4); // Refresh = post-write commands after the LAST burst (e.g. UC8179's 0x92), // then the refresh call minus any window-set it repeats. std::set all_wops; for (const auto &c : window0) all_wops.insert(c.cmd); for (const auto &c : window1) all_wops.insert(c.cmd); std::vector refresh_seq; if (!bursts.empty()) for (size_t i = bursts.back() + 1; i < image_stage.size() && image_stage[i].data.size() < PLANE_BURST_MIN; i++) refresh_seq.push_back(image_stage[i]); // A shared-region panel re-sets its one window in refresh() and we drop that // duplicate; a dual-region panel's refresh instead sets a DISTINCT both-region // window (its own select payload), which is part of the refresh - keep it. for (const auto &c : refresh_stage) if (dual_region || !all_wops.count(c.cmd)) refresh_seq.push_back(c); // The refresh ends at its trigger (the command we busy-wait on). A dual-region // driver re-sets the window AFTER the trigger to prime its own next writeImage; // our engine always sets its own window per cycle, so that trailing window is // dead in replay - trim anything past the last busy-wait. if (dual_region) { size_t last_wait = refresh_seq.size(); for (size_t i = 0; i < refresh_seq.size(); i++) if (refresh_seq[i].wait_busy) last_wait = i; if (last_wait < refresh_seq.size()) refresh_seq.resize(last_wait + 1); } // powerOff()/hibernate(), each its own fresh-session capture - see // capture_power_off_and_hibernate's doc comment. auto power_captured = capture_power_off_and_hibernate(); std::vector &power_off_seq = power_captured.first; std::vector &hibernate_seq = power_captured.second; // plane_write_cmd_for_full_refresh - computed here, BEFORE the "// GENERATED" // marker below, not alongside the printf()s that use it: every probe in this // function runs its own fresh Peek through an un-traced init()/ // writeScreenBuffer() first (see plane_burst_cmds), which prints raw // pinMode()/digitalWrite()/etc lines straight to stdout outside the // Trace::event_recording bracket - harmless for every OTHER probe above // because capture()/run_program() in tools/capture.py slice captured stdout // from the FIRST "// GENERATED" marker onward, dropping that noise. Calling // this probe any later - after the marker's printed - would leak its raw // trace lines into the middle of the emitted descriptor instead. const uint8_t full_refresh_extra = pv.is_1bpp ? probe_full_refresh_extra_write_cmd() : 0x00; // --- emit --- printf("// GENERATED by emit_descriptor from %s - candidate.\n", class_name); printf("// Command sequences, plane layout and plane_invert are observed here;\n"); printf("// for a packed panel bits_per_pixel/palette/blank_code are probe-filled\n"); printf("// when the colour family is recognised (4-colour/7-colour), else left TODO. A\n"); printf("// dual-region panel also gets plane_width and a candidate line_order; the\n"); printf("// exact PAIRED_EDGES row order still wants diag/driver confirmation.\n"); printf("#pragma once\n#include \"epd_command.h\"\n\nnamespace esphome::gxepd2 {\n\n"); if (pr.resolved && pr.two_code_bw) { printf("// 2-code packed mono (see .bits_per_pixel below): derived purely by observing\n"); printf("// raw white/black fills through the plane-write path, no colour wrapper to\n"); printf("// drive (see make_resolver_bw_packed in emit_descriptor.h).\n"); printf("inline const EpdPaletteEntry %s_PALETTE[] = {\n", macro); printf(" {0x00, 0x00, 0x00, 0x%02X}, // black\n", pr.black_code & 0xFF); printf(" {0xFF, 0xFF, 0xFF, 0x%02X}, // white\n", pr.blank_code & 0xFF); printf("};\n"); printf("inline const uint8_t %s_PALETTE_LEN = sizeof(%s_PALETTE) / sizeof(%s_PALETTE[0]);\n\n", macro, macro, macro); } auto emit_seq = [&](const char *seqsuffix, std::vector &cmds) { for (size_t i = 0; i < cmds.size(); i++) if (!cmds[i].data.empty()) { char suf[64]; snprintf(suf, sizeof(suf), "%s_%zu_%02X", seqsuffix, i, cmds[i].cmd); print_data_array(macro, suf, cmds[i].data); } printf("\ninline const EpdCommand %s_%s[] = {\n", macro, seqsuffix); for (size_t i = 0; i < cmds.size(); i++) { char suf[64]; snprintf(suf, sizeof(suf), "%s_%zu_%02X", seqsuffix, i, cmds[i].cmd); print_cmd(macro, suf, cmds[i]); } printf("};\n\n"); }; const bool w0_empty = window0.empty(); // e.g. OTP-default panels (F52) set no window emit_seq("INIT", init_seq); if (!w0_empty) emit_seq("WINDOW_SEQ0", window0); if (per_plane) emit_seq("WINDOW_SEQ1", window1); emit_seq("REFRESH", refresh_seq); const bool has_power_off = !power_off_seq.empty(); const bool has_hibernate = !hibernate_seq.empty(); if (has_power_off) emit_seq("POWER_OFF", power_off_seq); if (has_hibernate) emit_seq("HIBERNATE", hibernate_seq); printf("inline const EpdPanelDescriptor %s = {\n", macro); printf(" .width = %u,\n", (unsigned) Driver::WIDTH); printf(" .height = %u,\n", (unsigned) Driver::HEIGHT); printf(" .busy_active_level = %s,\n", bl == HIGH ? "true" : "false"); const char *depth_class = depth_bpp >= 4 ? "4bpp 7-colour" : depth_bpp == 2 ? "2bpp 4-colour" : "1bpp mono/3-colour"; printf(" .busy_timeout_ms = %u, // generous diagnostic ceiling (vendor full_refresh_time %ums, " "%s class); dev-tunable\n", (unsigned) default_busy_timeout_ms(Driver::full_refresh_time, depth_bpp), (unsigned) Driver::full_refresh_time, depth_class); printf(" .init_sequence = %s_INIT,\n", macro); printf(" .init_sequence_len = sizeof(%s_INIT) / sizeof(%s_INIT[0]),\n", macro, macro); if (per_plane) { printf(" .window_sequence = {%s_WINDOW_SEQ0, %s_WINDOW_SEQ1},\n", macro, macro); printf(" .window_sequence_len = {sizeof(%s_WINDOW_SEQ0) / sizeof(%s_WINDOW_SEQ0[0]), " "sizeof(%s_WINDOW_SEQ1) / sizeof(%s_WINDOW_SEQ1[0])},\n", macro, macro, macro, macro); printf(" .window_per_plane = true, // per-plane windows differ => separate regions\n"); } else if (w0_empty) { printf(" .window_sequence = {nullptr, nullptr}, // panel sets no window (OTP-default frame)\n"); printf(" .window_sequence_len = {0, 0},\n"); } else { printf(" .window_sequence = {%s_WINDOW_SEQ0, nullptr},\n", macro); printf(" .window_sequence_len = {sizeof(%s_WINDOW_SEQ0) / sizeof(%s_WINDOW_SEQ0[0]), 0},\n", macro, macro); } printf(" .plane_count = %zu,\n", nplanes); printf(" .plane_write_cmd = {0x%02X, 0x%02X},\n", p0cmd, p1cmd); // full_refresh_extra was computed above, before the "// GENERATED" marker - // see that computation's comment for why. A packed (>1bpp) driver's // writeImageForFullRefresh takes the same single-bitmap buffer shape, but // its _writeImage expects a buffer already packed to its own bit depth - // probing it with a 1bpp-sized buffer over/under-reads and crashes some // drivers (e.g. GDEP073E01, 4bpp 7-colour), hence the pv.is_1bpp gate above; // packed panels just keep the {0,0} default. if (full_refresh_extra) printf(" .plane_write_cmd_for_full_refresh = {0x%02X, 0x00}, // writeImageForFullRefresh also " "re-writes this \"previous RAM\" bank before the 0x%02X write above - not read by this " "full-refresh-only engine yet, captured for future partial-refresh use\n", full_refresh_extra, p0cmd); // Fields below are emitted in struct-declaration order (plane_invert, // plane_width, line_order, bits_per_pixel, palette, palette_len, // blank_code) so the designated initializer stays well-formed. if (pv.is_1bpp) { const bool i1 = (nplanes > 1 && pv.plane1_valid) ? pv.inv1 : false; printf(" .plane_invert = {%s, %s}, // probe-derived\n", pv.inv0 ? "true" : "false", i1 ? "true" : "false"); } else { printf(" .plane_invert = {false, false}, // unused: packed panel\n"); } if (per_plane) { if (dual_region && plane_width_val > 0) printf(" .plane_width = %d, // decoded from the region window-set (x_end-x+1); sanity-check\n", plane_width_val); else printf(" .plane_width = 0, // TODO: diag pass (separate-region width)\n"); } // line_order: a dual-region panel whose per-region burst is twice the region's // own pixel bytes is sending two wire lines per row (GxEPD2 writeNative's // doubled, both-edges-to-middle emission). The doubling is observable here; // the exact row order that PAIRED_EDGES encodes is NOT (a uniform fill can't // show which buffer row each line carries), so emit it as a candidate the // diag native-path pass / driver source confirms. if (dual_region && plane_width_val > 0 && depth_bpp > 0) { uint32_t expect = (uint32_t) plane_width_val * Driver::HEIGHT * depth_bpp / 8; if (expect && region0_burst == 2u * expect) printf(" .line_order = LineOrder::PAIRED_EDGES, // region burst %u B = 2x %u B (2 lines/row); " "confirm edges-to-middle order via the diag native-path pass\n", region0_burst, expect); else if (expect && region0_burst != expect) printf(" // NOTE: region burst %u B vs expected %u B (1 line/row) - line order unclear, " "check with the diag native-path pass\n", region0_burst, expect); } if (pv.is_1bpp) { // 1bpp plane model: depth confirmed by the probe; palette/blank_code unused. printf(" .bits_per_pixel = 1, // probe: white fill => all-bits wire byte 0x%02X\n", pv.w0_white & 0xFF); printf(" .palette = nullptr,\n"); printf(" .palette_len = 0,\n"); } else { // Packed panel. If a family resolver recognised it (4-colour, via // emit_palette_4c.h), it derived depth/palette/blank_code by observing the // driver's own colour fills - emit those (computed once, above). Otherwise // fall back to a TODO the diag pass fills in by hand (an unknown packed // family, e.g. a packed panel outside the 4-colour/7-colour resolvers). if (pr.resolved && pr.two_code_bw) { printf(" .bits_per_pixel = %d, // probe: %s\n", pr.bits_per_pixel, pr.note.c_str()); printf(" .palette = %s_PALETTE,\n", macro); printf(" .palette_len = %s_PALETTE_LEN,\n", macro); printf(" .blank_code = 0x%02X, // observed white-fill code\n", pr.blank_code & 0xFF); } else if (pr.resolved) { const int shown_colors = pr.color_count >= 0 ? pr.color_count : (1 << pr.bits_per_pixel); printf(" .bits_per_pixel = %d, // probe: %d-colour packed%s%s\n", pr.bits_per_pixel, shown_colors, pr.note.empty() ? "" : " - ", pr.note.c_str()); printf(" .palette = %s,\n", pr.palette_expr); printf(" .palette_len = %s,\n", pr.palette_len_expr); printf(" .blank_code = 0x%02X, // observed white fill code\n", pr.blank_code & 0xFF); } else { printf(" .bits_per_pixel = 2, // TODO: diag pass - PACKED (white fill wire 0x%02X, not 1bpp)%s%s\n", pv.w0_white & 0xFF, pr.note.empty() ? "" : " - ", pr.note.c_str()); printf(" .palette = nullptr, // TODO: diag pass\n"); printf(" .palette_len = 0,\n"); printf(" .blank_code = 0x00, // TODO: diag pass (clear fill observed 0x%02X)\n", clear_fill); } } printf(" .refresh_sequence = %s_REFRESH,\n", macro); printf(" .refresh_sequence_len = sizeof(%s_REFRESH) / sizeof(%s_REFRESH[0]),\n", macro, macro); if (has_power_off) { printf(" .power_off_sequence = %s_POWER_OFF, // probe: powerOff(), fresh session after a normal refresh\n", macro); printf(" .power_off_sequence_len = sizeof(%s_POWER_OFF) / sizeof(%s_POWER_OFF[0]),\n", macro, macro); } if (has_hibernate) { printf(" .hibernate_sequence = %s_HIBERNATE, // probe: hibernate()'s tail past powerOff() (see file header)\n", macro); printf(" .hibernate_sequence_len = sizeof(%s_HIBERNATE) / sizeof(%s_HIBERNATE[0]),\n", macro, macro); } printf("};\n\n} // namespace esphome::gxepd2\n"); } // Generic entry: no packed-family resolver, so a packed panel's // depth/palette/blank_code emit as TODO placeholders (the pre-resolver // behaviour). The 4-colour entry is emit_descriptor_4c() in emit_palette_4c.h. template void emit_descriptor(const char *class_name, const char *macro) { emit_descriptor_impl(class_name, macro, nullptr); } } // namespace emit