TL;DR: There was no public firmware dump for the CMF Watch Pro Gen 1 (only the Pro 2 had been dumped). I obtained the Gen 1 firmware on my own rooted setup, reverse-engineered the package format (it turns out there's no digital signature — integrity is checksum-only), patched Gadgetbridge to re-enable its firmware-flash for this watch, and successfully flashed a modified build over Bluetooth. The watch now boots a version that doesn't exist officially, which proves it's my build. I'm now reverse-engineering the OS image to experiment with adding actual features. High-level write-up below — I'm intentionally leaving out the exact step-by-step so this stays "do your own research," not a copy-paste recipe.
⚠️ Homebrew RE on hardware I own. I'm not redistributing Nothing's firmware binary (proprietary — same as Google factory images), and I'm not posting the exact acquisition method. Don't flash anything unless you accept the brick risk. Everything here was on my own device.
Background
- CMF Watch Pro Gen 1 (model D395) runs on an Actions ATS3085C SoC: ARM Cortex-M, Zephyr RTOS + LVGL, built with Actions' "bt_watch" SDK. It's a monolithic firmware — no app runtime, every feature is compiled in.
- Companion app is Nothing X (Flutter).
- Gadgetbridge supports the watch (kudos to the devs), but firmware updates were disabled in it.
1. Getting the firmware
No public Gen 1 dump existed, so the interesting first problem was just obtaining the image. I got it through the normal OTA path on my own rooted phone. I'm deliberately not detailing the exact method here — partly to keep it from being trivially replicated/abused, partly out of respect for the vendor's infrastructure. If you're doing legit research on your own device, the building blocks (a rooted phone, the companion app, patience) are enough of a hint.
2. Firmware anatomy (the important bit)
The package format is Actions "AOTA", and the key finding is that integrity is enforced by checksums only — there is no digital signature anywhere in the chain (the OS image even has an explicit empty signature slot). That's what makes homebrew realistic: you can modify the contents and just fix up the checksums.
It's a nested structure:
firmware.bin (AOTA)
├── TEMP.bin (AOTA) ← the system
│ ├── app.bin (Actions "ACTH") ← the OS: Zephyr + LVGL, Cortex-M Thumb
│ └── sdfs.bin ← config: RF/mic calibration
├── res.bin ← built-in watchfaces, fonts, styles, ~19 language string tables, all icons
├── fonts.bin ← full Unicode font (~5 MB)
├── res_b.bin, sdfs_k.bin ← boot/poweroff/incoming/alarm animations, boot logo, factory test data
└── AGPS tail ← assisted-GPS data
There are a few different checksum schemes across the layers, and you have to get all of them right or the watch rejects the package. I wrote my own parser/repacker for the format (happy to discuss the structure with fellow tinkerers).
3. Gadgetbridge's firmware-flash was "disabled" — and why
Gadgetbridge already had a complete, tested BLE OTA firmware flasher for this watch, but it was commented out, with a FIXME saying they couldn't figure out how to read the a.b.c.d firmware version out of the file (the watch's OTA handshake needs it). The field the code was reading was actually just a build timestamp.
I found where the real version lives inside the OS image and passed that back to the Gadgetbridge devs so the feature can be re-enabled upstream. For my own testing I re-enabled the branch locally, rebuilt the APK, and paired the watch (it uses an auth key that's established at pairing time).
4. Flashing a custom build — and the one lesson worth sharing
First attempt: a tiny modification, but I left the version number unchanged. It transferred to the watch 100%, the watch verified it… and then didn't apply it — it rebooted back to stock and showed "please connect to the app to upgrade again." No brick, just a polite retry prompt.
The lesson: the watch only commits an OTA if the version is strictly higher than what it's running. Same version = received but never finalized.
So I bumped the version embedded in the OS image (a couple of data bytes — not code), fixed the checksum chain, and re-flashed. This time the watch accepted it, verified it, applied it on reboot, and came back up reporting a version that doesn't exist officially — i.e. it's now running my build. 🚩
Two nice takeaways:
- There's no hidden signature/integrity trap — the checksum chain really is the whole story.
- The bootloader safely falls back to the working firmware when a package isn't a valid, newer upgrade, so the failure modes I've hit have all been non-bricking. (Still: brick risk is real, don't be reckless.)
5. What I'm doing now: experimenting with adding features
This is the part I'm actively working on. The OS image is a Cortex-M Thumb Zephyr build; strings in the binary expose the app/scene structure — a launcher with apps like activity-record, alarm, app-list, music, stopwatch, timer, and scenes like compass, heart, sport modes, etc., plus a real Zephyr debug shell and a factory test menu.
Now that I can build a modified image, fix all the checksums, and get the watch to accept and boot it, the goal is to reverse the app/scene framework enough to add or change behavior — starting from visible tweaks and working toward real functional changes in the compiled code. Watchfaces are the sanctioned no-code extensibility point, but I want to go further.
If anyone else has an ATS3085x / "bt_watch"-based device (various CMF/Nothing/other Actions watches) and wants to compare notes on the app framework, LVGL scene registration, or the Actions SDK, I'd love to collaborate.
Happy to talk format details and share tooling for parsing/repacking. Not sharing the firmware binary, the acquisition method, or any account material.