Skip to content

About

Firmware playground for the M5Stack StopWatch (ESP32-S3, 466px round AMOLED): tear-free layer engine, vector paths, text

Resources

Stars

1 star

Watchers

1 watching

Forks

Latest commit

 

History

15 Commits

Folders and files

Repository files navigation

m5-stopwatch

Playground for an M5Stack StopWatch (C152): ESP32-S3, 16 MB flash, 8 MB PSRAM, 1.75" 466×466 round AMOLED touch, IMU, RTC, mic/speaker, 450 mAh, USB-C.

Board docs: https://docs.m5stack.com/en/core/StopWatch

Contents

  • backups/factory-full-16MB.bin: full flash dump of the original M5Stack factory firmware (verified against the device; SHA-256 alongside).
  • badge-fw/v1.2.0/: WorkOS init() conference badge firmware + release.json (source: https://github.com/chantastic/stopwatch/tree/v1.2.0).
  • archive/: offline copy of the WorkOS badge pages, ESPtember guides, M5Stack docs and source repos (see archive/README.md).

Flashing

Download mode: plug in USB-C (data cable), hold PWR ~2 s until the green LED lights. Port is usually /dev/cu.usbmodem*.

Restore factory firmware:

uvx esptool --chip esp32s3 -p /dev/cu.usbmodem2101 -b 921600 write-flash 0 backups/factory-full-16MB.bin

Install init() v1.2.0 (from badge-fw/v1.2.0/):

uvx esptool --chip esp32s3 -p /dev/cu.usbmodem2101 -b 921600 erase-flash
uvx esptool --chip esp32s3 -p /dev/cu.usbmodem2101 -b 921600 write-flash --flash-mode dio --flash-freq 80m --flash-size 16MB 0x0 bootloader.bin 0x8000 partition-table.bin 0x10000 firmware.bin

After a full erase, finish at https://workos.com/init/badge/install → "Finish an interrupted first install" (prepares the ffat profile storage and syncs the clock). Without an erase, "Update badge" or "Set clock & check badge" suffices.

Building sketches

Toolchain: arduino-cli with M5Stack board package 3.3.9, M5Unified 0.2.19, M5GFX 0.2.26. Keep the default partition scheme (app3M_fat9M_16MB, same as init()) so uploads only replace the app and leave the init() profile storage (nvs, ffat) alone.

arduino-cli compile -b m5stack:esp32:m5stack_stopwatch --library lib/StopWatchDisplay sketches/ball
arduino-cli upload -b m5stack:esp32:m5stack_stopwatch -p "$(ls /dev/cu.usbmodem* | head -1)" sketches/ball
arduino-cli monitor -p "$(ls /dev/cu.usbmodem* | head -1)" -c baudrate=115200

The port name depends on the USB socket (/dev/cu.usbmodem101, ...2101, …).

The display library (lib/StopWatchDisplay, namespace swd)

Engine level (Engine.h, Layers.h), for apps:

  • Engine: begin() after M5.begin(), add(layer) back to front, frame() in loop(). Each frame waits for the next refresh, calls update() on every layer and redraws only what the layers invalidated (one full frame if that's most of the screen).
  • Layer: override update(FrameInfo) (main core: change state, invalidate(rect) what changes; FrameInfo::dt counts whole refreshes) and render(Span) (render core: only read state). A layer that animates more slowly just invalidates less often; areas redrawn for other layers show it unchanged.
  • Input arrives as events (Event: touch down/move/up, tap, button down/up/hold): the engine polls buttons and touch on the render core and delivers events in frame(), front-most layer first (onEvent() returns true to consume), then to engine.onEvent(handler). Other tasks (or serial test hooks) use engine.post().
  • Threading rule: everything on the loop task runs between draws, so it may change layers; other tasks must not touch layers, only post() events.
  • Idle: when no layer is animating(), nothing is invalidated and no events wait, frame() sleeps until an event (or idleTimeoutMs). Vsync waits block on a semaphore instead of spinning. engine.counters() reports frames, draws, idle sleeps and time asleep (sketches/idle_test: 99–100% asleep when untouched).
  • GradientLayer (interval N = update every N refreshes), SolidLayer, SpriteLayer (M5GFX canvas with a transparent key color; moveTo() invalidates old and new area), StatsLayer (fps and tear count pill; animating, so it keeps the engine awake).

Vector graphics (Vector.h, Path.h, Text.h):

  • Shape fast primitives (capsule, circle, ring, round rect) and ShapeLayer; ImageLayer (PSRAM raster cache: paint shapes/text into it once).
  • Path (SVG-style moveTo/lineTo/quadTo/cubicTo/close, rect, roundRect, circle, arc, pie) and Transform (translate/rotate/scale, then()); PathItem fills (exact area coverage, nonzero or even-odd) and strokes (turned into outline polygons with butt/ round/square caps and miter/round/bevel joins, filled the same way). Paint: solid, linear or radial gradients (up to 6 stops, in local coordinates, pad spread). Curves are flattened once in local coordinates; a new transform only moves the points.
  • TextItem: live text from glyph outlines (any size, color, rotation); setText() only redraws glyphs that changed; setTabularNumbers() for counters. Small sizes: no hinting; instead a coverage gamma (default 2.0, chosen by eye) and optional light vertical snapping (off by default; looked worse here). Fonts: swd::fonts::InterRegular / InterSemiBold (Inter, SIL OFL), generated by tools/font2cpp.py (uv run --with fonttools …).
  • VectorLayer holds PathItem, ShapeItem and TextItem; changing an item redraws its old and new per-band extent.
  • Costs (measured): watch hands ~4–5.5 ms per frame; the text_test stress (a 72 px counter changing every frame plus 29 rotating glyphs) 53–59.5 fps. Open: a glyph geometry cache (re-flattening changed glyphs costs up to ~5 ms per frame).

Low level (StopWatchDisplay.h): setupDisplay() (geometry fix), Vsync (TE refresh counter, measured period), FramePacer, StripRenderer (render callback, two-core SRAM strips DMA'd straight to the panel, circle-only spans, scan chasing, per-draw stats with a tear check, drawCount()/tornCount()), Overlay.

If the screen shows off-white noise after a crash loop while the log says frames are drawn, the panel is stuck: unplug USB, double-press PWR (off), then power on again. Restarting the ESP32 alone doesn't reset the display.

Don't draw with M5.Display while the engine runs: M5GFX's framebuffer no longer matches the screen and its next flush would overwrite it.

Display gotcha (from ESPtember): right after M5.begin(), set the panel to 468×466 with offset_x = 6, rotation 0, and never draw rows 466–467, or a black bar appears (setupDisplay() / Engine::begin() do this).

Display timing (measured)

The CO5300 refreshes at 59.68 Hz (16.76 ms) and pulses its tearing-effect (TE) pin, GPIO38, once per refresh. M5GFX enables TE but never reads it. After the TE edge the panel blanks for ~2.95 ms, then scans rows top to bottom at ~29.7 µs per row (13.8 ms for 466 rows): row r is scanned at TE + 2.95 ms + r × 29.7 µs. Period and row time come from sketches/scan_probe (moves the TE line with command 0x44); the blanking was tuned by eye with the ball sketch.

Tear-free rule: each row must be written entirely before or after the scan passes it, and all rows of one update must land in the same refresh. StripRenderer chases the scan by default (followScan): each strip waits until the scan has passed it, so an update appears exactly one refresh later. Its tear check measures every strip's DMA window and reports torn, slackUs and the refresh that shows it; sketches/tear_test validates it (chasing ON / OFF: 0 torn, NO SYNC: ~25% torn, matching what's visible).

Bus and frame times:

  • M5GFX's StopWatch setup draws into a PSRAM framebuffer and copies its dirty bounding box to the panel on endWrite(). Going through it, a full frame cost ~24 ms (two copies). StripRenderer bypasses it and DMAs SRAM strips straight to Panel_AMOLED: a full frame takes ~9.5 ms unsynced (~105 fps ceiling) and ~16.3 ms when chasing the scan, so the gradient runs full screen at 60 fps.
  • Rendering is now the bottleneck (~7–9 ms per gradient frame on the second core). Keep render callbacks lean (lastStats().waitRender).
  • A full frame written fast enough also lands cleanly without chasing (it starts before the scan and stays ahead, showing a refresh earlier with more slack). Partial updates and slow writes need chasing.

What runs where:

  • sketches/focus: the demo. A ring timer: drag around the rim to set 1–60 min (haptic click per minute), tap the center (or KEY A) to start/pause, KEY B rewinds. Shows partial redraws (60 arc segments + a tip), gradients, round/butt caps, live tabular text, transparency, haptics, and idling while nothing moves.
  • sketches/anim_bg: full-screen gradient at 60 fps (engine).
  • sketches/ball: ball over the gradient (engine). KEY A: gradient every refresh (full frames) or every second refresh (engine draws 30 full frames + 30 ball patches per second). Both 60 fps, 0 torn.
  • sketches/idle_test: static scene, a square follows your finger; shows idling.
  • sketches/watch: analog watch face (vector dial, path hands, sweeping seconds, RTC + TZ).
  • sketches/text_test: small-text quality comparison (plain vs tuned columns).
  • sketches/vector_test: fill rules, caps, joins and gradients on one sheet.
  • sketches/tear_test: tearing test pattern; sketches/scan_probe: scan timing probe.
  • Dead ends kept for reference: alternating screen halves (one half shows a refresh late, so things tear where they meet) and a scan model without the 2.95 ms blanking (patches land a refresh early: 30 fps look plus tearing).

M5GFX notes: Panel_AMOLED::command_list() sends raw panel commands (include lgfx/v1/panel/Panel_AMOLED.hpp); the real panel is the _panel member of M5.Display.getPanel() (a Panel_AMOLED_Framebuffer).

About

Firmware playground for the M5Stack StopWatch (ESP32-S3, 466px round AMOLED): tear-free layer engine, vector paths, text

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages