A from-scratch smartwatch firmware for touch-display boards, from the Tuya T5, the MaixCam-Pro to a growing set of Qualcomm-based Wear OS watches (Fossil Gen 4 and Gen 6, TicWatch C2 and S2) run bare-metal. On the Wear 2100 watches that means the whole stack with no Linux underneath: WiFi and BLE on the SoC's own radio, over-the-air updates, dual core, and a deep sleep that power-collapses the CPU cluster. It is a real little OS in miniature: a watch face, an app launcher, a notification pipeline that pulls from an HTTPS server and from your phone over BLE (iOS via ANCS, Android via the Gadgetbridge app), deep-sleep power management with a timed background-fetch wake, and a Player that mirrors and controls iPhone or Android media — all in a single Arduino translation unit on top of LVGL.
The board layer abstracts the pin map, panel/touch drivers, clock/RTC, power (PMU vs ADC) and screen geometry, so the same UI builds for both. UI layout that diverges is gated on screen geometry (portrait/narrow), not on the chip. PSRAM- only paths (the screen cache, big LVGL caches) compile out on the C6. Porting to a further board means adding a
board_*.hand its drivers.
![]() Watch face |
![]() Watch face (alt) |
![]() Dimmed / minimal face |
![]() App launcher |
![]() Notifications |
![]() Power |
![]() Appearance |
![]() Find Phone |
- Screenshots
- Installation — per-device guides in
docs/devices/ - Apps
- Architecture overview
- Deep sleep & the timed wake
- The notification pipeline
- Software updates over the air
- Storage (SD card vs flash)
- Experimental: overclocking & undervolting
- Credits & licensing
Installation is per-device. Each supported board has its own complete guide libraries, patches, board & partition selection, build settings, tunable options and flashing, so you only follow the steps that apply to the hardware you own.
| Device | SoC | Display | Install guide |
|---|---|---|---|
| Waveshare ESP32-S3-Touch-AMOLED-2.06 | ESP32-S3R8 | 410×502 CO5300 AMOLED | Guide |
| Waveshare ESP32-S3-Touch-AMOLED-1.8 | ESP32-S3R8 | 368×448 SH8601 AMOLED | Guide |
| Waveshare ESP32-S3-Touch-AMOLED-1.64 | ESP32-S3R8 | 280×456 SH8601 AMOLED | Guide |
| Waveshare ESP32-C6-Touch-LCD-1.47 | ESP32-C6 | 172×320 JD9853 LCD | Guide |
| Waveshare ESP32-S3-Touch-LCD-1.47 | ESP32-S3 | 172×320 JD9853 LCD | Guide |
| Waveshare ESP32-S3-Touch-LCD-2 | ESP32-S3R8 | 240×320 ST7789T3 LCD | Guide |
| Waveshare ESP32-S3-Touch-LCD-1.69 | ESP32-S3R8 | 240×280 ST7789V2 LCD | Guide |
| Waveshare T5-E1-Touch-AMOLED-1.75 | Tuya T5-E1 | 466×466 CO5300 AMOLED | Guide |
| Sipeed MaixCam-Pro | SG2002 (Linux) | MaixCDK-owned panel | Guide |
| Fossil Gen 6 (hoki) | Wear 4100 (SDA429W) | 416×416 AMOLED | Guide |
| Fossil Gen 4 (firefish/ray) | Wear 2100 (APQ8009W) | 454×454 AMOLED | Guide |
| Mobvoi TicWatch C2 / C2+ (skipjack) | Wear 2100 (APQ8009W) | 360×360 round AMOLED | Guide |
| Mobvoi TicWatch S2 / E2 (tunny) | Wear 2100 (APQ8009W) | 400×400 round AMOLED | Guide |
Most boards build in the Arduino IDE against the bundled libraries in
libraries/ (the exact versions the firmware was built against) plus the ESP32 core
from the Boards Manager. The MaixCam and the Qualcomm watches use their own
toolchains — the Fossil Gen 6, Fossil Gen 4, TicWatch C2 and TicWatch S2 are
bare-metal ARM, built with arm-none-eabi-gcc and run with fastboot as an
Android boot image. The three Wear 2100 watches can be RAM-booted without
overwriting Wear OS.
Shared reference: the out-of-tree library patches are documented once in
patches/README.md; every Arduino device page links to it.
The launcher (app_menu.h) is a scrollable grid on the top LVGL layer; the
watch face keeps running underneath. The hardware BOOT button is the universal
back/menu key (clock → menu → app → back out). Current apps:
| App | Icon | What it does |
|---|---|---|
| Notifications | 🔔 | Unified list/reader for HTTPS, ANCS (iPhone) and Gadgetbridge (Android) notifications; dismiss (clears on an iPhone too via ANCS) |
| Timer | 🔁 | Countdown timer + stopwatch + alarm; a running countdown arms a precise deep-sleep timer wake so the alarm fires on time |
| Appearance | 🎨 | Accent color / theme (mono-accent mode); restyles the live UI immediately |
| Power | 🔋 | Battery %, charge state, power estimates, the duty-cycle/floor-current learner, CPU usage, voltages, per-rail sleep-cut toggles, background-check toggle, battery-health proxy, change CPU clock speed |
| WiFi & BLE | 📶 | Toggle radios, saved WiFi networks, BLE pairing (shows the pair code), bonded phones |
| Player | 🎵 | Now-playing (iPhone via AMS, Android via Gadgetbridge): track/artist + transport controls |
| Find Phone | 📞 | Ring the phone (Gadgetbridge on Android, or a companion app) |
| Files | 💾 | Browse the SD card / flash FAT storage; view, move and delete files |
| Gallery | 🖼️ | Browse photos and videos on the SD card / flash: thumbnail grid, swipe pager with zoom + pan, video player, delete. No camera required — see the formats it reads |
| About | 📋 | Live spec sheet: chip/flash/PSRAM, per-pool SRAM & PSRAM free, SD usage, and reported library versions |
The Gallery reads files from /DCIM and the root of both stores. Decoding is done
on-device with no external libraries: media_codecs.h handles still images,
media_video.h handles containers. Everything is decoded straight to RGB565 and
downscaled during decode, so a 12-megapixel import costs a screen-sized buffer
rather than a 12-megapixel one.
| Format | Notes | |
|---|---|---|
| Photos | JPEG (.jpg, .jpeg, .jfif) |
Baseline. Progressive JPEGs are reported, not decoded — tjpgd cannot read them |
PNG (.png) |
1/2/4/8/16-bit, greyscale / RGB / palette / alpha, including Adam7-interlaced. Alpha is composited over black. Inflate comes from the ESP32 ROM, so it costs no flash | |
BMP (.bmp, .dib) |
1/4/8-bit palette, 16-bit 555 and BITFIELDS, 24-bit, 32-bit, either row order. RLE-compressed BMPs are reported, not decoded | |
GIF (.gif) |
Single-frame GIFs are treated as photos | |
| Videos | MJPEG in AVI (.avi) |
Any muxer's AVI, not just this firmware's recordings: the RIFF tree is parsed, the video track is located among several streams, and audio / index / padding chunks are skipped. Playback is paced by the file's own frame rate |
MJPEG stream (.mjpg, .mjpeg) |
Bare SOI/EOI-delimited streams, as IP cameras and ffmpeg -f mjpeg produce. The format carries no timing, so 10 fps is assumed |
|
GIF (.gif) |
Multi-frame GIFs play in the video player, honouring per-frame delays, transparency and all three disposal methods |
What it deliberately does not do. An ESP32-S3 has no video decoder and no headroom
for a software H.264/HEVC/VP9 one, and WebP/HEIC/AVIF decoders do not fit the flash
budget. Those files are still listed, as a placeholder tile naming the format
(MP4, WEBP, …) — a file that silently vanishes looks like a broken card, whereas
"H.264 not supported" tells you exactly what to convert. The same tile carries the
decoder's own reason when a supported file fails to decode.
To convert something the watch can read:
ffmpeg -i clip.mp4 -c:v mjpeg -q:v 5 -vf scale=320:-2 -r 15 -an VID_001.AVI
magick photo.heic -resize 800x800 IMG_001.JPGA pull-down quick shade (quick_shade.h) over the watch face gives one-drag access
to brightness and mute for sudden lighting changes (e.g. stepping into sunlight),
also including a caffeine button to keep the watch awake.
The watch has one physical control, the BOOT button. Everything else is touch.
| Action | What it does |
|---|---|
| Single tap | Open the app menu / go back one level (app → menu → watch face). Also closes the pull-down shade, or silences a ringing alarm, if one is up. |
| Double tap (two taps within ~½ second) | Put the watch into deep sleep. A single tap of BOOT wakes it again. |
| Hold | Nothing — holding BOOT has no special function. |
The watch also deep-sleeps on its own after ~2 minutes idle on the watch face (unless on USB power or kept awake by Caffeine); a single BOOT tap wakes it. See Deep sleep & the timed wake.
Everything is header-only, #include-d in dependency order into one .ino
translation unit. There is no RTOS app model; "apps" are just screen builders. Two
FreeRTOS tasks split the work across the two cores:
| Core | Runs | Why |
|---|---|---|
| Core 1 | loop() + all LVGL / display / touch |
UI must never block; it owns LVGL exclusively |
| Core 0 | network task (WiFi + HTTPS + NTP) and the NimBLE host task | these block for seconds; keeping them off core 1 keeps the UI smooth |
The network and BLE tasks write into shared stores under store_lock() and raise volatile
flags; the loop reads those flags and does all the actual UI (popups, bell refresh). This single rule
only the loop touches LVGL — is what keeps the design safe without heavy locking.
This varies across different devices, this is primarily explaining the ESP32 devices and the ideal hardware conditions. Deep sleep represents the OpenWatchFace goal of lowest possible power consumption. The single most important thing to understand about this watch's power model:
The bare-metal Qualcomm watches sleep differently. There is no cold boot: the firmware suspends in place. On the Wear 2100 watches (Gen 4, C2, S2) the runtime programs the SoC's power sequencers the way the vendor kernel does, hands the second core to TrustZone, collapses the whole CPU cluster and lets the RPM apply its sleep set with the crystal released, then resumes exactly where it left off on the pusher or the PMIC's RTC alarm. Measured with the C2's coulomb counter: about 6 mA asleep. The mechanics are in
snapdragon-port/README.mdand the per-watch guides; the rest of this section describes the ESP32 boards.
You cannot truly "turn the watch off" and still have it wake itself up. A device that is fully powered down has nothing running to decide when to come back. deep sleep is the next best thing: it powers down the CPU, radios, RAM and most peripherals, leaving only the ultra-low-power RTC + wake logic alive — just enough to be re-woken by a timer or a button. So the watch spends almost all of its life in deep sleep and briefly resurrects on a schedule.
When the watch decides to sleep (idle timeout, or user action), enter_deep_sleep()
→ arm_wakes_and_sleep() does, in order (sleep_power.h):
- Marks the sleep as intentional — writes
SLEEP_INTENT_MAGICinto RTC RAM so the next boot can tell a clean sleep/wake from a crash/reset. - Tears down BLE —
ble_end(). The BLE controller cannot run in deep sleep, so it must be stopped and (mostly) freed first. - Arms a timer wake — only if periodic background checks are enabled (a Power
app toggle) or a countdown timer is running:
If a countdown is due sooner than the next check, it wakes exactly when the alarm is due so the timer fires on time. If background checks are off and no timer is running, no timer wake is armed at all — the watch then sleeps indefinitely and spends zero battery on background work until you press the button.
esp_sleep_enable_timer_wakeup(interval_us);
- Arms a button wake — the BOOT button is configured as an
ext0wake on a LOW level, with its RTC-GPIO pull held through sleep. A press always wakes the watch instantly, regardless of the timer. - Latches the haptic motor LOW so it can't float-buzz while asleep.
- Cuts the proven-safe peripheral rails (see below), then calls
esp_deep_sleep_start(), which never returns. A wake is a full cold boot —setup()runs again from the top.
Side effect that's actually a feature: because every wake is a cold boot, any slow memory leak (e.g. the unpatchable IDF BT-controller leak when BLE is toggled repeatedly) is wiped clean on every sleep/wake. Manual heap, no GC — but deep sleep is the periodic reset that keeps long-term memory monotonicity from mattering.
A naive design would do a full boot every interval just to check for messages, lighting
the display each time and draining the battery. Instead, a timer wake runs a light
background check first (background_check_has_new()), and the display is only
initialized if there is actually something new:
deep sleep ──timer fires──► cold boot
│
▼
background_check_has_new() ← brings up ONLY I²C (PMU/RTC) + WiFi
│ (display stays DARK)
┌───────────────┴────────────────┐
something new? nothing new?
│ │
▼ ▼
full UI boot + show the card rearm_and_deep_sleep()
(display lights up) (re-arm timer + BOOT, sleep again,
screen never lit)
background_check_has_new():
- Brings up the PMU/RTC over I²C and connects WiFi (no display).
- GETs the HTTPS notify server (
notif_fetch_raw). If there's a new item (maxId > rtc_last_notif_id), it stashes it and returnstrue→ full boot. - If nothing new on the server and BLE is enabled, it brings BLE up for a ~9 s
window to let the iPhone reconnect and replay any pending ANCS notifications
(
ancs_background_check). A new one → full boot. - Otherwise →
rearm_and_deep_sleep(): re-arm the wakes and go straight back to sleep without ever initializing the display. The screen stays black, drawing almost no current.
The watch's awake/asleep split feeds a duty-cycle learner
(calib_note_awake_ms / calib_note_sleep_s) used to estimate the sleep-floor
current shown in the Power app. The split is measured from the RTC epoch gap between
drain_update() calls (short gap = awake, long gap = a deep-sleep interval), so it works
regardless of whether periodic background checks are enabled — which is what lets the
sleep-floor (and therefore battery-health) learning actually converge.
This applies to the S3 (the C6 has no PMU; it instead latches the backlight/reset GPIOs low under a GPIO hold before sleeping, since a C6 wake is a full RST reboot).
Deep sleep alone still leaves the S3's peripheral rails powered. To squeeze further, the watch cuts AXP2101 LDOs before sleeping — but cutting the wrong one is dangerous: ALDO1 supplies the PCF85063 RTC and the shared I²C bus. Cut it and, on wake, the PMU's own I²C dies and the RTC stops answering — the watch can't bring itself back.
So ALDO1 is hard-protected and never cut (RAIL_NEVER_CUT_MASK); it isn't even
listed in the Power app's rail toggles. Every other rail is enabled (cut) by
default — the validated win is cutting the sensor/codec/display rails together and
restoring them carefully on wake:
- On wake,
rails_restore()brings each cut rail back up one at a time (off→on with per-rail settle), never all at once: re-enabling the slower rails (ALDO2/ALDO4) simultaneously draws enough inrush to sag VSYS and brown out the PMU's own I²C. The AXP2101's enable-bit readback can also lie right after a deep-sleep cut (reads "on" while the LDO output has collapsed), so the restore force-cycles each rail rather than trusting the bit. - Boot bring-up is resilient:
board_power_begin()and the RTC probe retry (re-runningrails_restore()between tries), and a missing RTC is non-fatal — the watch continues rather than hanging. - The Power app shows how many rails are being cut ("Cutting X of 5" — five, because the protected ALDO1 is excluded from the count).
Recovery (blank/odd on wake): cold power-cycle (PWR off, then on) → the watch always boots stock with every rail restored.
Notifications converge from three independent sources into one shared store
(notif_store.h — a newest-32 NVS cache — backed by an unlimited archive on SD, or on
a flash FAT partition when there's no card). The UI never cares where a notification
came from; it just reads the store and the popup card.
A self-hosted server queues notifications; the watch GETs them over TLS
(notif_net.h):
WiFiClientSecurevalidates the server certificate against a pinned Let's Encrypt root (notify_ca.h), so the bearer token and notification bodies are encrypted and the watch is sure it's talking to the real server on any network.- The server returns items oldest-first as simple JSON; a tiny purpose-built extractor parses them (no JSON library). Each GET drains the server queue. Items are de-duped by id and rolled when the cache is full.
- All of this runs on the core-0 network task, which blocks for seconds without
ever freezing the UI. The loop raises
s_net_request; the task fetches and publishess_net_result_readyfor the loop to pop the card. - The same
notif_fetch_raw()is reused by the deep-sleep light-check path (no LVGL), which is why it does pure networking and never touches the display. - While the radio is up it also keeps the RTC synced from NTP (rate-limited), so the TLS cert's validity window can be checked against a correct clock.
ANCS (Apple Notification Center Service) is built into iOS and exposed to any
bonded BLE accessory — A companion app is available for extra features and easy connection.
Here the watch is the GATT client (the inverse of the rest
of its BLE code, which is a server). ble_ancs.h:
- On an encrypted + bonded link, discover the ANCS service and its three
characteristics:
- Notification Source (notify): 8-byte events —
EventID / Flags / CategoryID / Count / UID. - Control Point (write): the watch asks "give me attributes for UID X".
- Data Source (notify): iOS streams the attribute reply back (possibly across several GATT packets, which the watch reassembles).
- Notification Source (notify): 8-byte events —
- Subscribe to Notification Source + Data Source.
- For each ADDED event, write a Get Notification Attributes request for
AppIdentifier + Title + Message, parse the reassembled reply, and push
{title, body}into the same notification store the HTTPS path fills.
Key behaviors:
- Backlog replay / deep-sleep friendliness: on every fresh subscribe, iOS replays everything currently in the iPhone's Notification Center as a burst of ADDED events. So after a deep-sleep wake + reconnect, the watch receives all still-pending notifications — not just ones that arrive while connected. Cleared-on-phone items are correctly absent.
- Dedup across sources: ANCS UIDs are per-connection 32-bit ids while the HTTPS
path uses large millis-epoch ids. ANCS ids are mapped into a high id space
(
ANCS_ID_BASE | uid) so both live in one store with no collision, and a replayed backlog item already present is ignored. - The "duplicate missed call" split: a single missed call produces two ANCS
notifications — first a transient
INCOMING_CALL("X is calling…"), which iOS removes when the call ends, then a separateMISSED_CALL. The watch treatsINCOMING_CALLas a live, transient event (never stored; auto-cleared on its REMOVED) and lets onlyMISSED_CALLbecome the single stored notification — so one missed call yields exactly one card. This split is also the seam for a future live incoming-call screen with answer/decline via ANCS PerformAction. - Dismiss-through: swiping a notification away on the watch can send an ANCS negative action to clear it on the phone too.
- Threading: every ANCS callback runs on the NimBLE host task (core 0); it writes the store under lock and sets dirty flags, never touching LVGL.
BLE security: pairing uses LE Secure Connections + MITM + bonding. The watch shows a fresh 6-digit pair code (display-only IO) which the phone enters; up to 3 phones bond and bonds persist across deep sleep / reboot / reflash. The WiFi-provisioning characteristic requires an encrypted+authenticated link
AMS (Apple Media Service) is the media cousin of ANCS — same GATT-client-on-an-
inbound-connection pattern, same bond. ble_player_ams.h subscribes to:
- Player entity → PlaybackInfo (state / rate / elapsed),
- Track entity → Artist / Album / Title,
and pushes them into a source-agnostic player_state. The Player app's transport
buttons write 1-byte commands (play / pause / toggle / next / prev) back to the AMS
Remote Command characteristic. So the watch both mirrors now-playing metadata and
controls iPhone playback, over the same bonded link used for notifications.
Android has no ANCS equivalent — notifications are only visible to an app on the
phone, so one has to forward them. The watch speaks the Bangle.js protocol used by
Gadgetbridge (free, open source, on F-Droid): a Nordic
UART Service on the watch to which the phone writes newline-terminated JSON lines
(ble_gadgetbridge.h). Unlike ANCS, here the watch is the GATT server.
What works with an Android phone today:
- Notifications — posted and dismissed-on-phone events flow into the same store and popup path as ANCS/HTTPS; per-app categories work via the app name.
- Media — now-playing metadata and play/pause/next/prev from the Player app control whatever Android app is playing (Gadgetbridge media-session bridge).
- Time sync — Gadgetbridge pushes the time + timezone on every connect, so the RTC stays synced with WiFi off.
- Find My Phone / Find My Watch and battery reporting to the Gadgetbridge device card.
Setup: install Gadgetbridge on the phone you have to use F-droid, the app does not exist on the play store. scan for the watch in discovery from inside the Gadgetbridge app list, and pick "Bangle.js" as the device type (long-press the watch entry — it advertises under its board name, e.g. "Waveshare ESP32-S3-Touch-AMOLED-2.06" — then click on "Add test device" then "Select device" and select "Bangle.js" in the list, then press ok).
The watch updates itself from the project's GitHub releases. Nothing in the repo has to be edited or pushed for a release to be seen: on the watch, WiFi & BLE → Check for updates asks GitHub for the latest release and looks for an asset named for this board; if its version is newer, Install update appears.
- ESP32 boards with two app slots (the S3-2.06's
partitions_s3_32mb.csv) stream the image into the slot that is not running, verify it, and only then make it bootable. A failed download leaves the running firmware untouched and a build that fails to boot rolls back. - Fossil Gen 4, TicWatch C2 and S2 download the whole boot image into RAM
(resuming with HTTP range requests if the link stalls), verify it, and the
firmware's own eMMC driver writes it into the
bootpartition, header block last. Confirmed end to end on the Gen 4 with 1.5.0. There is no second slot on these watches, so everything is checked before the first block is written: size, the optional SHA-256, the Android boot magic, and a board marker compiled into every image so a C2 image can never be written onto an S2. This is the whole point of the feature on these watches: after the one cable flash, the soldered USB link is never needed again.
Publishing a release the watches will pick up:
- Bump
DEVICE_VERSIONinOpenWatchFace/device_info.hand build. ESP32: export the sketch binary (Arduino IDE: Sketch → Export Compiled Binary), the plain app image whose first byte is0xE9, not the merged flash image. Wear 2100 watches: the packed boot image fromtools/mk-bootimg-*.sh. - Create a GitHub release whose tag is the version (
V1.4.2orv1.4.2or1.4.2all work) and attach the images namedowf-<board key>-<version>.binfor ESP32 boards andowf-<board key>-<version>.imgfor the watches, for exampleowf-ws-s3-amoled-206-1.4.2.binorowf-ticwatch-s2-tunny-1.4.2.img. The board key isBOARD_OTA_KEYin that board's header. Boards without an asset report "No in ", which is the truth. - Optional but recommended: attach a
SHA256SUMSfile (sha256sum owf-* > SHA256SUMS). When it is present the installer refuses an image whose hash does not match.
Only a full release counts: a pre-release or a draft is not "latest" and the watches will not see it. GitHub allows 60 unauthenticated checks per hour per address; the watch reports a 403 as the rate limit.
python3 tools/test-ota-check.py runs the release-JSON parser and the version
compare natively against a fixture. The Tuya and MaixCam ports have no
installer; the check runs there and says so.
Three kinds of persistent data — battery-health learning, WiFi credentials, and notification history — are written to a removable card when one is present, and fall back to on-board flash otherwise:
- microSD card, formatted FAT32 — if a card is inserted and mounts, it becomes the
store: the full unlimited notification archive, the saved WiFi networks (
/wifi.csv), and the battery-health/cycle log all live on the card. - FFat flash partition — with no card (or a card that won't mount), the firmware transparently uses the on-board FAT partition in flash instead, so every feature still works. The notification archive is then bounded by the flash partition size (~24 MB) rather than the card's capacity.
In both cases the newest-32 notification cache also lives in NVS, so the instant-wake popup path needs no mount; the archive (SD or FFat) is the source of truth the Notifications app and bell read.
Recommendation: use a microSD card (FAT32). (Does not work if formatted in Windows default formatter) It gives you a much larger notification history, keeps WiFi credentials and the battery-health log off the wear-sensitive app flash, and survives reflashing the firmware. The watch works fully without one, but a card is the better experience.
Two opt-in, compile-time tuning knobs sit behind self-test "canaries" that auto-revert before instability can corrupt anything. Both default OFF, so the stock build is byte-for-byte the known-good baseline.
Scope: overclocking now works on both boards, via the same BBPLL trick (the S3 and C6 share the identical feedback-divider register and
PLL = 40·(div+4)formula; the IDF's "C6 SPLL is fixed at 480 MHz" is a software policy, not a hardware lock). Only the CPU re-lock differs: S3 isCPU = PLL/2(260/280/300 MHz) and C6 isCPU = SPLL/3(173/187/200 MHz, up from the 160 MHz stock ceiling). The code path is board-gated inoverclock.h. Undervolting also exists on both boards but uses a different mechanism per chip (S3 trims the on-die LDOdig_dbias; the C6, which has no AXP2101, trims the PMU'shp_dbiasinstead). The descriptions below are the S3 unless noted.
⚠️ Testing / benchmarking only — NOT for normal use, and incompatible with the radios. Overclocking breaks WiFi and BLE: the ESP32-S3 cannot decouple the APB bus clock from the CPU (a singleCPUPERIOD_SELfield drives both, with no independent APB divider), so raising the CPU drags APB above its stock 80 MHz. The WiFi MAC and the (closed-source) BLE controller count all their protocol timing in APB cycles and are hard-wired to 80 MHz, so at a higher APB their slot/connection timing drifts out of spec and the links drop. This cannot be fixed in firmware — the radio stack is a precompiled Espressif blob with APB = 80 MHz baked in, and the hardware lacks the separate APB divider (the ESP32-P4 has one; the S3 does not). So overclocking is only usable with WiFi and BLE turned off — which for a watch whose whole point is the phone link means it's a bench-test feature, not a mode you'd run day to day. It also moves the USB 48 MHz clock off-spec (~300 MHz kills USB; reboot to flash). The C6 has separate AHB/APB/MSPI dividers (the S3 doesn't), which sounds like it could hold APB at spec — but they only step in coarse ratios that can't match a non-integer SPLL bump, so the C6 loses the radios for the same reason. Ships disabled (OVERCLOCK_ENABLE 0); the real, radio-safe performance wins are the async DMA flush and the PSRAM screen cache (S3), not this.
Pushes the CPU past the stock ceiling (S3 240 MHz, C6 160 MHz). Both chips derive the
CPU clock from the BBPLL, so the bump retunes only the PLL feedback divider via an
exact IDF register recipe (PLL = 40·(div+4), landing on a fixed grid). The only
per-chip difference is the CPU divide: S3 CPU = PLL/2 (e.g. 260/280/300 MHz) and
C6 CPU = SPLL/3 (e.g. 173/187/200 MHz). Memory is not re-divided, so flash
(and PSRAM, S3) ride up with the PLL — which is also why too high a clock corrupts
RAM/flash. On the C6 there are separate PCR dividers for AHB/APB/MSPI, but they
only step in coarse ratios (AHB ÷12/24/48, APB ÷1/2/4), so a non-integer SPLL bump can't
be pulled back to exactly the stock bus speed — APB/flash still ride up ~8–25% and the
radio is still expected to drop (same outcome as the S3, below). The C6 is also a single
RISC-V core with less voltage margin, so its stable window is narrower.
How it stays safe:
- Never at boot. Overclocking is button-triggered from the Power app, so the watch always boots stock with working USB and stays flashable normally — no ROM download mode needed. If a value kills USB (the PLL also moves the USB 48 MHz clock; ~300 MHz breaks it, ~260 survives), just reboot to get USB back.
- Cache-off critical section in IRAM. The actual PLL switch runs with the flash
cache disabled, so the whole switch routine lives in IRAM and uses only inline
clk_ll_*/ ROM calls — executing from flash here would crash mid-switch. - RAM-corruption canary. Right after the bump it re-runs a trusted compute self-test and (where the chip has PSRAM — S3) a 64 KB PSRAM write/read pattern; any miscompare → it immediately reverts to stock (S3 480/240, C6 480/160) and flags the failure. So an unstable clock can't silently corrupt memory — it's caught and undone within milliseconds. (The C6 has no PSRAM, so only the compute self-test runs there.)
- Hang recovery. A stage marker in RTC-noinit memory survives a reset; if a switch hangs hard, tap RST (keep power) and the next boot reports where it died and comes up stock. A higher clock also wants more core voltage, so it temporarily forces the core voltage up for the duration (overriding the undervolt table below).
The opposite knob: lowers the chip's actual digital-core voltage by overriding the
dig_dbias trim the IDF programs into the on-die LDO (this is the real silicon knob,
not the AXP2101 rail trick). Since power ≈ V², dropping the core a notch cuts dynamic
power meaningfully — the biggest wins are at the low clocks (80/160 MHz), which IDF
leaves badly over-volted at this board's flash speed. You set a target millivolt per
CPU speed in a small table; the code snaps it to the nearest hardware step and clamps
to a safe floor.
How it stays safe:
- Compute+memory canary, not a brownout banner. Too-low core voltage doesn't brown out cleanly — it causes silent logic/RAM/flash corruption (wrong arithmetic, random crashes, bad writes). So the guard is the same compute+SRAM self-test: the boot path captures a "golden" result at the trusted stock voltage, applies the table, then re-checks. Mismatch → auto-revert to 1.25 V and flag it, so a too-aggressive value can't brick a boot.
- Re-applied after every clock change.
setCpuFrequencyMhz()rewritesdig_dbiasfor the new frequency, so the override is re-applied after each frequency change. - Always reversible.
dig_dbiasresets to the IDF default on every reboot and the targets live only in the source table, so changing or backing out a value is just a reflash — never a brick. Tune one step at a time and validate it under load (max brightness + WiFi fetch + busy CPU) for a long while; expect silicon lottery.
This firmware is licensed under the GNU General Public License v3.0 (see the
LICENSE file). It stands on the shoulders of several open-source libraries, bundled
in libraries/ at the exact versions the firmware was built against so the build is
reproducible. Each retains its own license; the modifications this project makes to a few
of them are shipped as documented patches in patches/ (none change a
library's license, and all are GPL-compatible).
| Library | Version | Author | License |
|---|---|---|---|
| LVGL | 9.5.0 | LVGL community | MIT |
Arduino_GFX (GFX_Library_for_Arduino) |
1.6.5 | Moon On Our Nation | BSD |
| Arduino_DriveBus | 1.0.1 | Xk_w | GPL-3.0 |
| SensorLib | 0.4.1 | Lewis He | MIT |
| XPowersLib | 0.3.3 | Lewis He | MIT |
The ESP32 Arduino core (Espressif Systems, v3.3.11, Apache-2.0) is installed
separately via the Boards Manager (see Installation); the patches this
project makes to it (the bundled BLE and ESP_I2S libraries, and Esp.cpp) are in
patches/.
- Hardware: Waveshare; this firmware builds on Waveshare's references.
- Apple ANCS (Apple Notification Center Service) and AMS (Apple Media Service) are Apple-defined BLE services used by the notification and player integrations.
- AsteroidOS — the Fossil ports owe an enormous
amount to this project. Their device trees, kernels and documentation made
porting this firmware to Qualcomm-based Fossil watches vastly easier than
starting from nothing: their work is what turns an opaque, undocumented piece
of hardware into something you can actually reason about, and their
boot.imgis also what makes a rooted shell — and therefore a stock backup — possible before flashing. Thank you to the AsteroidOS developers.
Created by Noel Ejemyr.
Modified-library notice (per the bundled libraries' license terms): the changes this project makes to LVGL, Arduino_GFX, and the ESP32 core (
BLE,ESP_I2S,Esp.cpp) are described and provided as unified-diff patches inpatches/, each marked in-source with aLOCAL PATCH (OpenWatchFace)comment.







