back to archive

Reverse Engineering MoYoung Watch Faces in the Browser

A gifted Porodo watch, no SDK, and a binary format nobody documented. How I ended up writing a watch face editor.

Reverse Engineering MoYoung Watch Faces in the Browser
on this page14 sections

My brother gave me a smartwatch as a gift. Round face, decent screen, no brand I recognised. I flipped it over and the back said Porodo. For what he paid, it was a good deal.

It took about ten minutes of searching to work out that Porodo did not make it. The watch is designed and manufactured by MoYoung and rebranded, which is how a large slice of the cheap smartwatch market actually works. My unit reports itself as MOYOUNG-V2 running firmware MOY-GKE5-2.2.7, and the model name burned into it is Vortex.

That single fact, that the watch is a MoYoung under a sticker, is what made the next few months possible.

Two apps for the same watch

Porodo ships its own companion app, Porodo Fit. I installed it because that is what the manual said to do, used it for a while, and then found Da Fit, which is MoYoung's own app. After using both, Porodo Fit looks like a reskin of Da Fit with features removed. Da Fit has the full watch face catalogue, a couple of hundred faces for my round model, sorted into categories.

So I switched to Da Fit and started poking at the watch instead of just wearing it. First stop was Gadgetbridge from F-Droid, since MoYoung turns up in its support forum and pull requests. I installed it and my watch was not in the supported device list at all, so it never even got as far as pairing. Dead end, but a cheap one.

What I actually wanted was to change the watch faces. Not pick from a list, change them. This is a cheap watch running firmware rather than Wear OS, so there is no APK to sideload and no theme engine to hook. A watch face is a binary blob, and that is the whole story.

The two tools that already existed

Searching around the format led me to two repos by david47k:

  • dawft, which dumps and rebuilds watch face binaries
  • dawfu, which uploads them over Bluetooth Low Energy

Reading both READMEs told me the plan was viable: dawft could take a .bin apart into BMPs and put it back together, and dawfu could flash the result. Someone had already done the hard reverse engineering.

I had exactly one problem. I did not have a single .bin file, and nothing in the Da Fit app offers to give you one. The faces live on MoYoung's servers and go straight to the watch.

Asking the vendor first

Before trying anything clever I emailed MoYoung and asked whether they had a watch face SDK. Xiaomi ships a theme editor for its phones, so it did not seem like an absurd question. I mentioned what I had already looked at, including nRF Connect and the open source projects, and said outright that I would rather do this the supported way than reverse engineer anything.

They replied, which I did not expect from a Shenzhen ODM answering a hobbyist in India:

Thank you so much for your heartfelt email and for your strong interest in our products. However, at this time, we regret to inform you that we do not have a publicly available official SDK or developer program for creating custom watch faces. Should any developer opportunities or resources become available in the future, we will be sure to reach out.

The MoYoung Team

A polite no is still an answer, and it settled the question. There was no official path, so the format was fair game. I shelved the project for a few months.

The log file

Months later I came back to it from a different angle. Instead of asking how to download a watch face, I asked what the app knows while it is sending one.

Da Fit writes verbose logs to its own app data directory. I plugged my phone into my laptop and went looking:

/storage/Android/data/com.crrepa.band.dafit/files/moy_logs/

Inside, one text file per session. I changed the watch face in the app to generate a fresh log, opened the newest file, searched for .bin, and there it was, sitting in an analytics event with a mix of Chinese and English:

plain
05-10 14:28:08.090 D/TrackHelper(7683): logEvent:表盘传输状态码: Bundle[{watchFaceDownloadLink=https://qn-hscdn2.moyoung.com/files/KjrVjvJHaXiqnI8cTvMFvIDwmVqpzsRz.bin, watchFaceId=16433, transportWatchFaceStateCode=0}]

A face ID, a transfer status code, and a plain CDN URL with no auth on it. I downloaded the .bin with curl and it came down fine.

The app was telling me exactly what I wanted to know. I just had to read its diary instead of its UI.

I then changed the watch face in Da Fit several more times purely to harvest logs, and collected a handful of real face binaries that way.

The first dump

With a real .bin in hand, dawft did its job on the first try:

bash
./dawft dump folder=faces/Vortex/16433/dump faces/Vortex/16433/KjrVjvJHaXiqnI8cTvMFvIDwmVqpzsRz.bin

The dump folder came out with 99 bitmaps, 000.bmp through 098.bmp, plus a watchface.txt describing how they are assembled. Every element of the face is in there as a separate image: 33 frames of the progress ring, a heart icon, all twelve month abbreviations, three complete sets of digits at different sizes, weekday names, and small steps and kcal labels.

That is when the format clicked for me. There is no vector rendering and no font engine on this watch. A watch face is a pile of pre-rendered bitmaps plus a text file saying where to stamp each one.

Changing green to blue

My brother and I both liked one particular face, but he was using the green version and green is not my colour. My entire goal, the thing that started the actual project, was to make it blue.

Editing 99 BMPs by hand in GIMP was obviously not happening. I tried ImageMagick recolouring first, fiddling with hue rotation and colourspace conversions, and could not land on the exact blue I wanted after several attempts. The results were either washed out or the wrong shade.

Then I stopped trying to recolour and thought about what the pixels actually contained. The design is green, white, and grey. If I swap the green and blue channels, green becomes blue, and every grey or white pixel is untouched because those have equal R, G, and B values, so swapping two of them is a no-op. That is not a colour transform, it is a channel permutation, and it is exact:

bash
for i in {000..033}; do
  '/home/elabins/Downloads/ImageMagick-7.1.2-21-gcc-x86_64.AppImage' "$i.bmp" \
    -channel RGB -separate -swap 1,2 -combine "$i.bmp"
done

I ran that over the ranges that actually had green in them: the ring frames, the large digit set, and the two labels. The folder went from green to blue in a couple of seconds with no quality loss and no guessing at hex values.

Dump folder showing the original green watch face assetsThe dump folder as it came off the watch face binary.
Same dump folder after swapping the green and blue channelsAfter the channel swap. Note the white digits are untouched, because swapping two equal channels does nothing.

The moment of truth

Repack, then flash:

plain
dawfu: Da Watch Face Uploader - Face Uploader for MO YOUNG / DA FIT Smart Watches
Starting Bluetooth (BLE) scan using adapter hci0 (usb:v1D6Bp0246d0553)...
Found device [F9:E0:1A:65:02:39]: Vortex. Software Revision: MOY-GKE5-2.2.7
Serial Number:     628c18b9
Manufacturer:      MOYOUNG-V2
Battery Level:     31
Sending watch face...
100   % All data recived by watch. Checksum: ffff1a29 (-58839)
File send finished!
Disconnecting.

It worked. My watch was running a face that did not come from anybody's catalogue. I wrote the whole flow down in a scratch document so future me would not have to rediscover the log directory.

Why I built an editor anyway

The CLI flow works, and for a channel swap it is genuinely the right tool. But it has a hard ceiling: I can only modify a face that already exists. I cannot move an element, cannot change which slot a bitmap belongs to, cannot add a step counter to a face that does not have one, and cannot see what I am doing until it is on my wrist.

The gap is not the codec, it is the lack of a canvas. So a few days later I started DaFit Canvas, a browser-based visual editor for these faces. The first commit landed on 10 May 2026.

The stack choice was mostly about friction. React 19, TypeScript, and Vite, with Zustand for the editor store and plain HTML5 Canvas for rendering. It runs entirely in the browser with no backend, because the watch is reachable over Web Bluetooth and everything else is local file parsing. No install, no Electron bundle, no server bill. The whole thing is a static site.

What a Type C face actually is

This is the part I had to work out properly, because writing an editor means writing an encoder, and the encoder has to be exactly right or the firmware renders garbage.

My watch uses what dawft calls a Type C binary. The header is 1900 bytes and contains, among other things, two parallel tables:

plain
offset 400   offsets[250]   u32 each, little-endian, blob start relative to end of header
offset 1400  sizes[250]     u16 each, little-endian

Then the blob data, then a watchface.txt-equivalent element list. Each element is a type byte, a starting blob index, and an x, y, width, height rectangle. The type byte determines how many blobs it consumes: a background is one blob, a digit set is ten, a weekday name set is seven, month names are twelve, and a progress bar is eleven.

Pixels are 16-bit RGB565, little-endian, no alpha, on a 240 by 240 display:

ts
const r = (pixel.r >> 3) & 0x1f
const g = (pixel.g >> 2) & 0x3f
const b = (pixel.b >> 3) & 0x1f
const rgb565 = (r << 11) | (g << 5) | b

Simple enough on paper. Then I started exporting faces and watching them break on the actual watch, which is where the real learning happened.

Four bugs that taught me the format

The sizes table is a trap

I filled in sizes[250] with each blob's byte length, which is what the field name obviously means. Faces rendered, mostly, but battery and animation elements collapsed to their first frame permanently.

The firmware does not use that table for blob layout. It uses offsets[] exclusively, and it reads sizes[0] as the project-wide animation frame count. By writing a byte length into slot 0, I was telling the watch the animation had some absurd number of frames, and the frame index calculation fell apart. The fix was to stop writing per-blob sizes entirely and put only the animation frame count at offset 1400.

A field can be named one thing and mean another. The firmware is the spec, not the field name.

Blob order is load bearing

Progress bars kept sticking on frame 0 even after the sizes fix. The element list said the right thing, the blobs were all present, and it still did not animate.

It turned out the firmware expects blobs in a specific order in the file: progress bar types first, then multi-blob sets by descending frame count, then single-blob items. Getting the declared indices right is not enough, the physical ordering matters too. I now sort asset sets that way at pack time.

Compression is per blob and must survive a round trip

Blobs are individually either RLE line encoded or raw. If you decode a face, change one digit, and re-encode everything uniformly, the firmware mis-decodes the blobs whose encoding you silently changed.

So every asset slot in my project model carries a compression hint recorded at import time, and that hint is preserved through editing, saved into the project ZIP, and restored on load. Round-trip safety is a feature you have to design in, not something you get for free.

One type was documented wrong

0xda, which dawft calls BATT_IMG_D, is listed as a single static image. On my firmware it is an eleven frame battery fill animation. I found this by dumping a corpus of real faces and noticing the blob accounting did not add up for any face using that type. Reclassifying it fixed both the preview and the export.

Use at your own risk. This is hobby software written against a reverse engineered format. Flashing a malformed face has never bricked my watch, but I make no guarantees. Try it on a watch you can afford to wipe.

The corpus is the real documentation

At some point I stopped guessing and started measuring. I downloaded a large set of real watch faces and wrote a script to analyse them, then let the statistics tell me what the firmware accepts.

That turned several of my assumptions into numbers. fileID is 0x81 in 84 percent of faces, but 0x84 shows up in 14 percent and 0x04 in 2 percent, so hardcoding 0x81 in the decoder would have broken imports for one face in six. animationFrames is zero in 76 percent of faces. And 64 percent of faces have two or more elements pointing at the same blob range, which is how they save space by sharing one digit set between the hour and minute fields.

That sharing is the interesting one, because it is firmware policed rather than free form. All four time digit slots can share, month and day digits can share, and the calorie, step, and heart rate digit sets can share with each other. But zero faces in the entire corpus share between two progress bars, which strongly suggests the firmware does not support it. So my editor refuses that combination instead of letting you build a face that renders as noise.

Statistics over real files beat my intuition about the format every single time.

The editor's asset library listing each bitmap set with its slot count and dimensionsHow the editor models a face. Each row is one asset set: `HR_PROGBAR` is 11 frames at 240 by 240, `TIME_H1` is 10 digits at 37 by 45 shared across 4 layers. That last one is blob sharing made visible.

Cutting the uploader out of the loop

The last piece was flashing from the browser. I had already reverse engineered the Da Fit Android app to pull out MoYoung's face catalogue APIs, so the editor can browse and download faces directly instead of me digging through log files for CDN URLs. That closed the loop that started this whole thing.

The editor's watch face browser showing 387 faces for firmware MOY-GKE5-2.2.7The catalogue, queried directly with my firmware string. 387 faces across 8 pages, filterable by the same tags Da Fit uses. No more grepping log files for CDN URLs.

For upload, the watch exposes a small BLE surface:

UUID

Purpose

0xfeea

MoYoung service

0xfee2

control commands, write without response

0xfee6

file chunk payload, write without response

0xfee3

notify, watch acknowledgements

0x180a / 0x180f

standard device info and battery services

Web Bluetooth handles all of that, so the editor flashes the watch directly from a Chromium browser with no dawfu install. One caveat cost me an evening: Android Chrome serialises GATT operations far more strictly than desktop Chrome. My original code read the device info characteristics concurrently with Promise.all, which works on desktop and fails on Android with the gloriously unhelpful GATT operation failed for unknown reason. Replacing the parallel reads with sequential awaits fixed Android and cost desktop a few milliseconds nobody will notice.

Where it ended up

Thirty four commits over about two weeks of evenings turned the "make it blue" script into something I actually use. The editor imports real .bin files, gives you a layer list and a live 240 by 240 canvas with drag, multi select, align and distribute, snapping guides, and undo. It generates digit sets from any system font, Google Font, or uploaded TTF, so you can restyle every number on a face without opening an image editor. There is a built-in BMP pixel editor for touch ups, animation support with playback controls, a BLE console for poking at the watch protocol directly, and direct flashing over Web Bluetooth.

 The same face the ImageMagick loop turned blue, except now every element is a layer I can select, nudge, and swap. The SEPERATOR typo in the layer names is the firmware's, not mine.

What I would tell past me

Ask the vendor first. It costs one email, and a documented no is worth more than an assumption.

Then, when you do go digging, look at what the software already tells you before you try to break anything. I spent months assuming I would need to intercept traffic or decompile an APK to find a watch face binary, and the URL was sitting in a plaintext log file the whole time.

And once you are writing an encoder, measure instead of reasoning. Every one of my four format bugs came from believing a field name or a comment over the behaviour of real files. The corpus was right every time.

I did not get an SDK. I got something better suited to what I actually wanted, which is a watch face editor that runs in a browser tab and talks to a cheap watch nobody wrote documentation for.

DaFit Canvas runs entirely in the browser, so there is nothing to install. It needs a Chromium browser if you want the Bluetooth flashing to work.

Dafit Canvas
app
Dafit Canvas

Browser-based visual editor for designing smartwatch faces compatible with the Da Fit / MoYoung ecosystem. Import an existing Type C .bin, swap assets, retouch positions, regenerate digits from a font, and export a watch-ready binary — without ever touching a hex editor.

e-labInnovations/dafit-canvas

DaFit Canvas is a browser-based visual editor for designing smartwatch faces compatible with the Da Fit / MoYoung ecosystem. Import an existing Type C .bin, swap assets, retouch positions, regenerate digits from a font, and export a watch-ready binary — without ever touching a hex editor.

ashadblebluetoothcanvasdafit
TypeScript 1 0

Built on top of the reverse engineering work in dawft and dawfu by david47k. Without those two repos this project would have started several months earlier and finished several months later.

david47k
david47k@david47k
14 7
david47k/dawft

Da Watch Face Tool (dawft): Tool for MO YOUNG / DA FIT binary watch face files. Allows you to dump (unpack) or create the files.

C 6 0
david47k/dawfu

Da Watch Face Uploader (dawfu): Face Uploader for MO YOUNG / DA FIT Smart Watches

Rust 3 1MIT
e-lab innovations

Browse the archive for more, or subscribe to get new posts in your inbox.

Discussion 0 comments

Be kind. I read everything but might take a day or two to reply.

no comments yet — be the first