Add Murphy M3 (HamGeek M3 / 墨菲) hardware support - #25
Open
mr-tbot wants to merge 8 commits into
Open
Conversation
The profile carried a pre-audio-recovery SPI pin guess (39/13/40/10) that conflicts with the proven I2S (39/40/41/42) and I2C (13) owners, plus NO_SDMMC — so SD never worked on the M3. The stock firmware mounts the card via SD_MMC (4-bit), and the reverse schematic confirms the wiring: D1=14 D0=15 CLK=16 CMD=17 D3=18 D2=21, with the card rail gated by GPIO10 through an AO3401 P-FET (active-LOW enable). SdPins now carries only the active-low powerEnable=10 (the existing SdmmcBlockDevice pulse-HIGH-then-hold-LOW mount sequence matches this gate exactly), the sdmmc field owns the bus at width 4, and FREEINK_SD_SDMMC auto-enables for MURPHY. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Display (fixes heavy ghosting): - The DEFAULT LUT bank was the OEM's five per-mode voltage-config blocks (0f8f4f/...) misread as waveforms — no kick phase, no DC balance, so residual charge accumulated on every refresh. Replaced with the OEM three-phase set (kick -> settle -> drive) extracted from the v525 dump, and the FAST bank with the community-sdk destination-drive-only derivation. All five registers take 42-byte payloads (the 56-byte figure came from address-gap guessing). - The driver now always writes the same buffer to DTM1 and DTM2 (OEM scheme). The differential branch was dead on single-buffer builds and empirically leaves pixels half-flipped on this panel. Touch (fixes touch dead on M3): - GPIO45 is a rail gate (CH442E + AO3401, ACTIVE-LOW), not a controller reset: moved from TouchConfig.reset to powerEnable with powerEnableActiveHigh=false. Hardware-probed: touch and the ES8388 codec vanish from the bus with GPIO45 high. - Touch rail power-up hoisted from beginGt911() into beginTouch() so CHSC6x boards get it too. - CHSC6x frame read uses a stop-separated transaction: this panel's controller NACKs repeated-start reads (hardware-probed). - Wire timeout 4ms -> 10ms for headroom on the shared gated bus. Board: - GPIO43 gates the ENTIRE I2C rail (touch/ES8388/RX8010/AHT30); carried as power.latch0 so holdPowerRails() asserts it at boot. - uiScale set to 0.6 (240x416 logical canvas at ~130 PPI) as advisory. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Touch coordinate fix (taps were transposed): - decodeChsc6xFrame now applies the swapXY/flipX/flipY mount transform, same scheme as the GT911 path — it previously ignored those flags entirely (Murphy is the only CHSC6x board, so it went unnoticed). - MurphyM3 TouchConfig: swapXY=true with post-swap ranges (x 24/398 long axis, y 24/224 short axis), flipY=true. The digitizer reports raw x over the short axis but the framebuffer is landscape 416x240, so without the swap a normalized short-axis fraction was multiplied by the long screen dimension — every tap landed transposed. Gesture usability on small digitizers: - TOUCH_TAP_SLOP_PX / TOUCH_SWIPE_MIN_PX are tuned in GT911 (800x480) mapped units; on the Murphy's 200x374 space they demanded 3-4x the physical finger travel, absorbing swipes into taps. New touchTapSlopPx()/touchSwipeMinPx() scale by the digitizer's average span (GT911 boards unchanged). Matters doubly on the M3: the left-edge back-swipe is the device's only Back, and the top-edge swipe its only reader menu. - Touch held-time now measures to the last real sample instead of the TOUCH_IRQ_PULSE_MS hold-over expiry, which inflated every contact by ~120ms and ate into the swipe window and long-press thresholds. Display: ghostClearInterval 1 — every refresh runs the OEM three-phase GC bank. The OEM has no fast waveform on the mode-0 path; our single-phase FAST bank is DC-unbalanced by construction and leaves drifting afterimages (hardware-confirmed). Also corrected the LUT header history note: the five manufacturer blocks are the OEM's ALTERNATE LUT bank (0x17/0xA5 partial path package), not voltage config. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Battery percentage was nonsense because the profile carried a stock- firmware-derived x3.0303 divider guess: the ADC pin reads ~2.07V with a charging pack, which x3.03 puts at an impossible 6.3V. The reverse schematic's 680k/680k divider (x2.0) yields 4.15V — a textbook charging LiPo. Hardware-verified via the probe build's per-second VBAT log. Charging status: GPIO47 (the only unaccounted pin) probe-read static across plug/unplug, so the TP4054's CHRG pin drives only its LED and no status GPIO exists. New FREEINK_USB_PRESENCE_AS_CHARGING (default: only Murphy) reports USB-host presence instead, detected with zero wiring via the USB-Serial/JTAG SOF frame counter, which advances at 1kHz only while a host is attached. Used only when batteryChargeStatus is unassigned. Touch mount transform is now hardware-confirmed: all four corner taps land exactly (probe log, 2026-07-29). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Consumer firmware needs the same signal BatteryMonitor's fallback uses: CrossPoint's charging bolt reads its HalGPIO::isUsbConnected(), which returns constant false on boards with no VBUS-detect GPIO. A shared inline helper lets both layers report USB-host presence from the USB-Serial/JTAG SOF counter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PanelDriver's displayGray DEFAULT refreshes the caller's fb with the FAST bank — but at that point fb holds a GRAY PLANE, not a frame. Every grayscale-capable driver overrides it; the Murphy driver (B/W only, no grayscale hooks) inherited the default, so every EPUB/TXT/XTC page and grayscale sleep cover ended with an extra refresh of the MSB plane: a near-solid-black page (hardware-reported). displayGray is now a no-op on Murphy. New PanelDriver::supportsGrayscale() (default true; Murphy false) is exposed through FreeInkDisplay so consumers can skip building gray planes entirely on B/W-only panels — 4-level grayscale AA was tried on this panel and abandoned (asymmetric VSH/VSL rails, per the Murphy repo findings). ghostClearInterval 1 -> 6: with the correct OEM banks in place, the non-flashing FAST waveform returns for page turns/UI, with every 7th refresh promoted to the three-phase GC flash to clear residue. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reading at ghostClearInterval=6 left a distracting residue between GC flashes (hardware-reported). Two levers: the FAST bank's destination drive now runs two identical groups instead of one (stronger latch, less residue per non-flashing refresh, update slightly longer), and the GC promotion tightens to every 5th refresh. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… hold FAST now runs the OEM's real quick-refresh package (Ghidra FUN_420389ec else-branch / FUN_42038b60 / FUN_42038f74): the data-driven init variant (0x82=0x07, 0x50=0xD7, pointer-fed 0x00/0x01/0x06/0x61 payloads — recovered from the app0 segment dump the findings doc cites, byte-exact in the v525 binary too), the five-payload ALT LUT bank (56/42/56/42/42), and the 0x17/0xA5 auto trigger (internal PON->DRF->POF; no window bytes exist in the OEM path). Differential planes per the doc: prev->DTM1, new->DTM2, falling back to both-planes-new on single-buffer builds. GC promotion every Nth fast refresh retained. Escape hatch: -DFREEINK_MURPHY_OEM_PARTIAL=0 restores the synthetic fast bank. Known open point (FIXME'd): the OEM ALT loader's steady-state 0x22/0x23 mapping is crossed vs the findings doc's array names (one-shot RTC flag branch); shipped the steady-state arrangement — swap bw/wb in ALT_BANK if changed pixels invert on hardware. Input: CONFIRM_POWER_HOLD_MS 400 -> 1500 on Murphy — the shared confirm/power pin is the primary select there, and 400ms fired sleep on merely deliberate presses (hardware-reported). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Add Murphy M3 (HamGeek M3 / 墨菲) hardware support
This adds working hardware support for the Murphy M3 — an ESP32-S3R8 e-reader
sold as the HamGeek M3 / corogoo 墨菲 ("MoFei"), with a 3.7" 240×416 UC8253
panel, CHSC6x capacitive touch, ES8388 audio codec, RX8010 RTC, AHT30
temperature/humidity sensor, a frontlight, and a 4-bit SDMMC microSD slot.
The SDK already carried a
MurphyM3board profile and aUc8253MurphyDriver,but several subsystems were stubbed, mis-wired, or wired against guessed values.
Every change here was diagnosed on real hardware and confirmed working on real
hardware. Where a value came out of the OEM firmware I say so and give the
provenance, because a few of them are not guessable.
Why this is worth taking even if you don't have the device
Four of these changes are not Murphy-specific:
PanelDriver::supportsGrayscale()— a capability flag that lets consumersskip building grayscale planes for panels that can't show them. Without it,
the
displayGraydefault silently renders a raw gray plane as if it were aB/W frame.
freeink::usbHostPresent()— USB host detection with no dedicated pin, bywatching the USB-Serial/JTAG start-of-frame counter. Any board whose charger
IC doesn't expose a status line can use it.
being fixed pixel counts, so small panels aren't forced into an unusable
swipe distance.
previously entangled with the GT911 path.
The work, subsystem by subsystem
1. microSD: real 4-bit SDMMC wiring
The profile's SD pins were placeholders, so the card never mounted. Correct
wiring, verified with a 32 GB card:
powerEnablebeing active-low matters: driving it the usual way keeps the cardpowered off, which presents exactly like a dead slot.
2. Touch: three stacked faults
Touch reported nothing at all. There were three independent problems:
is an active-LOW rail gate, and with it high both the touch controller and
the ES8388 disappear from the I²C bus. It is now
powerEnablewithactiveHigh = false.board profile's
power.latch0, soholdPowerRails()raises it before anyI²C user runs.
controller NAKs. Reads are now stop-separated, with a 10 ms Wire timeout.
The power/rail block was also hoisted out of
beginGt911()intobeginTouch(),since it is not GT911-specific.
Axis mapping. The CHSC6x decoder ignored the profile's
swapXY/flipflags (they were only honoured on the GT911 path), and this digitizer reports
its short axis as X while the framebuffer is landscape. The transform is now
applied in
decodeChsc6xFrame, withswapXY = true,flipY = true, andpost-swap ranges. Verified by tapping all four corners and reading back the
logical coordinates: top-left (0,0), top-right (239,0), bottom-left (0,413),
bottom-right (239,410).
Gestures. Tap-slop and swipe-minimum were fixed pixel values tuned for a
480×800 panel; on a 240×416 canvas a swipe was nearly impossible. They now
scale with the digitizer span. Separately, held-time was inflated by a 120 ms
holdover, which made short taps read as long presses.
3. Display: the OEM waveforms, and why the previous ones couldn't work
The LUT tables in
Uc8253MurphyLuts.hwere the OEM's voltage-configurationblocks (
0f8f4f…), not waveforms. They contain no kick phase and noDC balance, so the panel ghosted badly and never latched cleanly.
Replaced with the genuine OEM sets, recovered from the stock firmware image and
re-verified byte-for-byte against the OEM touch v525 build:
DEFAULT): the OEM three-phase set — inversion kick,settle, destination drive. DC-balanced; this is what actually clears ghosting.
only works as a whole: the data-driven init variant (
0x82 = 0x07,0x50 = 0xD7, plus pointer-fed0x00/0x01/0x06/0x61payloads), the alternateLUT bank, and the
0x17/0xA5auto refresh trigger. Loaded under the mode-0init instead, these same tables flash without latching — a trap worth
documenting, since it looks like a bad LUT rather than a mismatched package.
Build with
-DFREEINK_MURPHY_OEM_PARTIAL=0to fall back to a syntheticdestination-drive bank under the mode-0 init.
Fast refreshes are differential (previous frame to DTM1, new frame to DTM2) and
every Nth is promoted to a full refresh, since partial kicks accumulate residue.
Grayscale. 4-level grayscale was attempted on this panel and abandoned: the
VSH/VSL rails are asymmetric and the result is unusable. That made the
PanelDriver::displayGraydefault actively harmful here — it re-displays therenderer's gray plane as a B/W frame, producing a near-solid black page. The
driver now overrides it as a no-op and reports
supportsGrayscale() == false,which is plumbed through so consumers skip building the planes at all (also a
straight speed win on page turns).
4. Battery: the divider is 2.0, not 3.03
VBAT is read on GPIO9 through a 680 kΩ/680 kΩ divider — a factor of 2.0. The
profile carried 3.0303, which reported a charging LiPo as 6.3 V. Measured
2.07 V at the pin, i.e. 4.15 V at the cell.
5. Charging indicator without a charge pin
The TP4054's
CHRGoutput only drives its own LED; it is not exposed to theSoC. I confirmed this by probing the one unaccounted GPIO (47) across plug and
unplug cycles — static in both.
Instead, host presence is detected from the USB-Serial/JTAG frame counter
(
USB_SERIAL_JTAG_FRAM_NUM_REG): if start-of-frame packets are arriving, a hostis attached. Exposed as
freeink::usbHostPresent()inBoardConfig.handselected per-board with
FREEINK_USB_PRESENCE_AS_CHARGING.BatteryMonitorfalls back to it when
chargeStatusPinis unassigned. This is a"USB connected", not a true "charging" signal, and is commented as such.
6. Sleep hold time on a shared button
GPIO0 is both a boot strap and the M3's primary confirm button, and it doubles
as the power button. At the shared 400 ms hold, ordinary selects tripped sleep.
CONFIRM_POWER_HOLD_MSis now 1500 ms on this device only.Hardware findings that may be useful regardless
Probed and confirmed on the device:
burned, so the device is effectively unbrickable — worth knowing before
anyone hesitates to flash one.
One trap for anyone else developing on this board: after an esptool or
PlatformIO session, the reset leaves the download strap latched, so the device
comes back in ROM download mode rather than running the app. A physical replug
clears it, as does an RTC-WDT reset issued over JTAG.
Testing
All of the above was verified on hardware, iteratively, with a human at the
device reporting results and serial captures for the parts that log. Concretely
confirmed working: SD mount and file listing from a 32 GB card, touch across the
full panel with all four corners landing exactly, swipe and tap gestures,
full-refresh and partial-refresh quality (no visible ghosting or afterimage),
battery percentage tracking a real charge, the charging indicator following
plug/unplug, frontlight control, sleep only on a deliberate long press, and
books rendering correctly rather than as dark pages.
Not included here
Audio, MOBI, PDF and the reader-side UI work live in the application layer and
are proposed separately. The RX8010 RTC and AHT30 drivers are not written yet.