Skip to content

Add Murphy M3 (HamGeek M3 / 墨菲) hardware support - #25

Open
mr-tbot wants to merge 8 commits into
Free-Ink:mainfrom
mr-tbot:murphy-m3-support
Open

Add Murphy M3 (HamGeek M3 / 墨菲) hardware support#25
mr-tbot wants to merge 8 commits into
Free-Ink:mainfrom
mr-tbot:murphy-m3-support

Conversation

@mr-tbot

@mr-tbot mr-tbot commented Jul 29, 2026

Copy link
Copy Markdown

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 MurphyM3 board profile and a Uc8253MurphyDriver,
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 consumers
    skip building grayscale planes for panels that can't show them. Without it,
    the displayGray default silently renders a raw gray plane as if it were a
    B/W frame.
  • freeink::usbHostPresent() — USB host detection with no dedicated pin, by
    watching the USB-Serial/JTAG start-of-frame counter. Any board whose charger
    IC doesn't expose a status line can use it.
  • Gesture thresholds that scale with the digitizer's coordinate span instead of
    being fixed pixel counts, so small panels aren't forced into an unusable
    swipe distance.
  • Touch power/rail sequencing separated from controller-specific init, which was
    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:

CLK 16   CMD 17   D0 15   D1 14   D2 21   D3 18   powerEnable 10 (active LOW)

powerEnable being active-low matters: driving it the usual way keeps the card
powered off, which presents exactly like a dead slot.

2. Touch: three stacked faults

Touch reported nothing at all. There were three independent problems:

  1. GPIO45 was recorded as the touch controller's reset line. It is not — it
    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 powerEnable with
    activeHigh = false.
  2. GPIO43 gates the entire I²C rail and nothing asserted it. It is now the
    board profile's power.latch0, so holdPowerRails() raises it before any
    I²C user runs.
  3. CHSC6x reads were issued as repeated-start transactions, which this
    controller NAKs. Reads are now stop-separated, with a 10 ms Wire timeout.

The power/rail block was also hoisted out of beginGt911() into beginTouch(),
since it is not GT911-specific.

Axis mapping. The CHSC6x decoder ignored the profile's swapXY/flip
flags (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, with swapXY = true, flipY = true, and
post-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.h were the OEM's voltage-configuration
blocks
(0f8f4f…), not waveforms. They contain no kick phase and no
DC 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:

  • Full refresh (DEFAULT): the OEM three-phase set — inversion kick,
    settle, destination drive. DC-balanced; this is what actually clears ghosting.
  • Fast refresh: the OEM's alternate package. This is a package deal and
    only works as a whole: the data-driven init variant (0x82 = 0x07,
    0x50 = 0xD7, plus pointer-fed 0x00/0x01/0x06/0x61 payloads), the alternate
    LUT bank, and the 0x17/0xA5 auto refresh trigger. Loaded under the mode-0
    init 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=0 to fall back to a synthetic
    destination-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::displayGray default actively harmful here — it re-displays the
renderer'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 CHRG output only drives its own LED; it is not exposed to the
SoC. 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 host
is attached. Exposed as freeink::usbHostPresent() in BoardConfig.h and
selected per-board with FREEINK_USB_PRESENCE_AS_CHARGING. BatteryMonitor
falls back to it when chargeStatusPin is 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_MS is now 1500 ms on this device only.

Hardware findings that may be useful regardless

Probed and confirmed on the device:

Address Device Notes
0x10 ES8388 audio codec behind the GPIO45 gate
0x2e CHSC6x touch behind the GPIO45 gate
0x32 RX8010 RTC present and ticking; no SDK driver yet
0x38 AHT30 temp/humidity present; no SDK driver yet
  • GPIO43 gates the whole I²C rail.
  • GPIO45 is an active-LOW gate for touch and the codec.
  • GPIO47 is unaccounted for and is not a charge-status pin.
  • GPIO33–37 are the octal PSRAM lines.
  • The USB-Serial/JTAG bootloader is always reachable and no security eFuses are
    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.

mr-tbot and others added 8 commits July 29, 2026 20:38
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant