back to archive

Hacking Another ₹249 Smartwatch: Custom Firmware on PHY6222

My second ₹249 smartwatch hid a PHY6222. I found its reset pin, stole the display init from the stock firmware, and updated it over BLE.

The LP715 watch board and its 80x160 display showing the custom graphics test, on a dark workbench
on this page15 sections

I hacked one more cheap Chinese FitPro smartwatch. The last one was a TLSR8232 watch I opened for a bike navigation idea. This one was bought for a different project: a soldering pen with a T12 tip, powered over USB-C PD.

When I read rbaron's M6 band writeup again, the band's long, narrow display looked like the right shape for the side of a pen. The band also has a tilt sensor, a touch key, and a few GPIOs exposed as test pads (RX, TX and some others). A display, an input, a motion sensor and BLE, already assembled, for the price of a nice lunch. That is a dev board if you squint.

Same price, different silicon

I searched for M6-style bands and found one on JioMart for the same ₹249 as the last watch.

₹249, down from a fictional ₹1,999. The quantity says 2, because one watch per hack has never been enough.

It sat in my cart for a few weeks before I checked out. Two boxes arrived, one branded Melbon and one Morden, both with a big "M6" on the front. I did not look closely enough to notice they were two different designs. I opened the Melbon one, and the Morden one went to the shelf for some later project.

Two boxes, two brands, and as I found out much later, two different watches.

I assumed it was the same TLSR8232 platform as the last watch, charged the Melbon for a few hours, looked at the stock watch face, and opened it.

The stock face, the last time it ran. 01/01 because it was never paired with the phone app.

It was not a TLSR8232. The chip said PHY6222QC, a Phyplus BLE 5 SoC with a Cortex-M0 core and 512 KB of flash. Last time, the wrong chip meant a refund. This time the name looked familiar: Amir (amir1387aht), the same person who gave me the display library last time, had already hacked a PHY6222 watch and published a working SDK in his phy6222_smartwatch repo. Silicon lottery, second draw, and this time the consolation prize came with a toolchain.

Out of the case: board, battery, vibration motor and display.
PHY6222QC in the middle, a 16 MHz crystal next to it, and no 32 kHz crystal anywhere. That last detail comes back much later.

The board is marked LP715_MB_V1.3, and the stock firmware calls itself LP715(D). So this is the FitPro LP715, a close cousin of the LT715 I hacked before, with a completely different chip inside.

A UART bootloader, and a reset pin I did not have

The PHY6222 is friendlier than the TLSR8232 in one important way: its ROM has a UART bootloader. No custom programmer, no single-wire protocol, no Blue Pill. You need RX, TX, GND and a reset line, and pvvx's rdwr_phy62x2.py (shipped in Amir's repo) talks to the ROM directly.

The test pads are split across both sides of the board. RX, TM and EN are on the back next to the display connector; TX, GND and VCC3V are on the front, behind the display.

Back of the board: RX, HR, TM, EN and VBUS pads.
The other side: TX, GND and VCC3V.

The catch was the reset. I went over my FTDI USB-to-UART board looking for a pin labelled RST, found none, and asked ChatGPT how to handle that. It made me more confused than when I started. So I soldered thin enamelled copper wire to RX, TX, GND and VCC, skipped the reset, and tried anyway:

plain
python rdwr_phy62x2.py -p /dev/ttyUSB0 -b 115200 i
=========================================================
PHY62x2 Utility version 11.03.24
---------------------------------------------------------
Connecting...
PHY62x2 - Error Reset!
Check connection TX->RX, RX<-TX, RTS->RESET and Chip Power!

The error message already says RTS goes to RESET, but the script itself explains the full dance better than any answer I got:

pythonrdwr_phy62x2.py
self._port.setDTR(True) #TM   (lo)
self._port.setRTS(True) #RSTN (lo)
time.sleep(0.1)
self._port.flushOutput()
self._port.flushInput()
time.sleep(0.1)
self._port.setDTR(False) #TM  (hi)
self._port.setRTS(False) #RSTN (hi)

DTR drives the TM (test mode) pin and RTS drives the chip's reset, and both go low, wait, and go high together. For getting into the bootloader they move in lockstep, so DTR, which sits on my board's header, can stand in for the reset line. Amir's repo has a photo marking the capacitor on the chip's reset line, so I soldered one more wire to that capacitor, connected it to DTR, and ran the same command:

plain
python rdwr_phy62x2.py -p /dev/ttyUSB0 -b 115200 i
=========================================================
PHY62x2 Utility version 11.03.24
---------------------------------------------------------
Connecting...
PHY62x2 - Reset Ok
Revision: b'001360eb 6222M005'
FlashID: 1360eb, size: 512 kbytes
PHY62x2 - connected Ok
Flash Status: 0x00
Flash Serial Number: 1120031301040812

One borrowed pin, compared to a full day of ff ff ff ff last time.

Only later did I notice that the board had an RTS pin all along. It is on the row of pads along the bottom edge, where I had never soldered a header, and I had been scanning the silkscreen for "RST" the whole time. Three letters, two of them right.

The FT232RL module I was using. DTR is on the header; RTS is the bare pad on the bottom row, labelled with an R, a T and an S, in that order.https://yellsalejuk.click/product_details/32985689.html

Later I wired TM and reset to separate lines, because the difference matters once you want to run firmware: the same reset pulse either starts the app or stays in the ROM bootloader, depending on the TM level during reset.

Enamelled wire on the front of the board.
And on the back, next to the RX and TM pads.

Thirty-five minutes for 512 KB

The rule from last time: dump the flash before you flash anything. Reading it was painfully slow. I stopped the first attempt, raised the baud rate to 200000, and it still took around 35 minutes for 512 KB.

The baud rate was never the bottleneck. The read command in rdwr_phy62x2.py fetches the flash one 32-bit word per request: send a command, wait for the ROM to answer with four bytes, repeat. 512 KB is 131,072 round trips, and 35 minutes divided by that is about 16 ms each, which is UART latency and USB polling, not bandwidth. A faster baud rate shortens the part of each round trip that was already short.

I did it twice, on different days, so I had two dumps to compare. They differed in exactly 13 bytes, all inside the sector at 0x7b000. The stock firmware keeps a small log-structured settings store there: one record had its header flipped from 0x8e (valid) to 0x8c (obsolete), and an identical copy was appended after it. Every changed bit went from 1 to 0, which is what a flash write without an erase looks like. So the dumps were both good, and the watch had simply saved its settings in between.

The workflow, and who did the typing

From here most of the work was done with Claude Code, and I want to be accurate about what that looked like, because it was not "AI writes firmware."

Claude Code built the firmware in Docker, flashed it with rdwr_phy62x2.py, and read the UART log back. I did the part no tool can do: tap the button, look at the screen, feel whether the motor buzzed, and report back. "Screen dim" and "it's restarting again and again" were real messages I sent.

The other half was the stock firmware. Claude ran Ghidra headless, with a few Java scripts to set up the PHY6222 memory map and load the ROM symbols, and most of what follows came out of reading the stock code rather than guessing.

The restore that erased everything

A backup is only a backup once you have restored from it. rdwr_phy62x2.py has a write command, we, so the first test was to write the stock dump back.

It erased all eight 64 KB blocks and then hung. The watch was now blank.

Reading the script explained both problems. The ROM needs two flash setup commands (spifs 0 1 3 0 and sfmod 2 2) before it will program anything, and only the hex-file path (wh) sends them; we skips them, so the ROM accepts the data and never replies. Worse, we passes 0x1FFF0000 + offset as the RAM buffer for each block, and the ROM's own variables live at the start of that RAM. For offsets near zero, the tool was overwriting the bootloader it was talking to.

So the repo got its own restore_full.py: send the setup commands, stage every block in a RAM buffer at 0x1FFF8000 that nothing else uses, check the ROM's checksum for each block, and resume from an offset when the link stalls (it does, every so often, at 500000 baud). The first full restore read back 1,403 words from all over the flash with zero differences. That is the moment the project stopped being scary.

The boot table pointed somewhere else

Loading the dump into Ghidra had its own detour. The ROM boots from a table at flash 0x2000 listing segments to copy into RAM, so the obvious setup was to load those segments and start reading. The display code made no sense that way: the SPI init function appeared to have no callers at all.

It turned out the boot table does not load the watch app. It loads a bootloader, and the bootloader has its own table at 0x3000 describing the app: eleven partitions, eight running in place from flash and three copied into RAM from a base offset the table does not state. The base was found by brute force: try each candidate, follow the veneer stubs the app uses to jump from flash into RAM, and count how many land on a function prologue. The right base, flash 0x11000, put 13 of 18 targets on a push {..., lr}; the next best candidate managed 6. With the app's RAM image in the right place, the dead SPI init had callers again.

That bootloader turned out to be the most useful thing on the chip. Hold that thought.

The display was not what the SDK expected

Amir's SDK ships an ST7735 driver, because his watch has one. Mine stayed black. Instead of guessing a controller, I read how the stock firmware chooses one, and it turned out the stock firmware does not know either. At boot it bit-bangs command 0x04 (read display ID) over the SPI pins, reads three bytes, and looks them up in a table of seven panel profiles, each with its own init sequence, sleep sequence and pixel offsets:

Profile

Panel ID bytes

Controller family

0

00 91 06, 00 91 08

GC9106

1

33 30 23 (fallback)

NV3023

2

00 91 07

GC9107-type

3

33 30 25

NV3025-type

4

7C 89 F0 and four others

ST7735S

5

00 91 09

GC9109-type

6

98 50 00

JD9850-type

The manufacturer ships whichever panel is cheapest that month and lets the firmware sort it out. So the first firmware I ran was a probe that does the same ID read, copied from the stock routine, including its single dummy clock before the data:

plain
RDDID(04) = 98 50 00
raw 04 no-dummy = 4c 28 00 00
DA DB DC = 31 a0 00

The second line is the same read without the dummy clock: 4c 28 00 is 98 50 00 shifted right by one bit, which is how you know the dummy clock is real and not a superstition copied from the stock code. Profile 6, a JD9850-type controller, 80x160 pixels. Its init sequence sits in the stock firmware at flash 0x3815c as 16-bit words (0xff means a command follows, 0xfe a delay), so the driver uses the stock sequence decoded byte for byte. No JD9850 datasheet required.

The graphics test. The gfx layer and fonts are the ones I ported for the TLSR8232 watch, ported back to the SoC they originally came from.

The backlight was another small surprise. There is no single enable pin, and the stock firmware uses no PWM. Four GPIOs (P01, P02, P16, P17) each sink part of the LED current through their own resistor, all active low. All four low is full brightness, all high is off, and P17 alone is the only one that visibly lights the screen. The stock firmware only ever switches all four together, so it has no brightness setting at all; using them as four steps gives five levels for free.

Reading the pin map out of the stock firmware

Last time the pin map cost me a soldering session per pin with a multimeter. This time every pin came out of the stock code first and was then confirmed on the watch, one small example firmware per pin:

Pin

Function

Notes

P24, P25, P31, P32, P34

Display RST, DC, CS, SDA, SCL

SPI0, CS and DC driven by hand

P01, P02, P16, P17

Backlight

Four active-low current sinks

P11

Touch key

Active high, long press 1.5 s, very long 2.5 s

P03

Vibration motor

Active high, needs battery power to spin

P18

Tilt switch

A ball switch, not an accelerometer

P14

Battery voltage

ADC through a 1/5.5 divider

P15

Charger detect

Only valid while P23 is low

P23

Charge enable

The one that took the longest

P00

"Heart rate" LED

Active low

P07

Factory test strap

Low at boot enters test mode

Two entries in that table are about honesty in marketing. There is no accelerometer: no code touches either I2C controller, and the "motion sensor" is a ball switch that bounces when you shake it, counted in bursts with a 350 ms timeout. And there is no heart-rate sensor either. The stock "heart rate" feature blinks the green LED on the back for up to 60 seconds, then shows a number picked with osal_rand() from a table of plausible values. The LED is real. The measurement is a random number.

“

The LED is real. The measurement is a random number.

The battery that would not charge

One night I left the watch connected to the FTDI board, running a test firmware that never slept, and by morning the battery was flat. I plugged in the charger, and the watch read about 3.2 V and stayed there. The charger was detected, the battery was not charging, and for a while I was convinced I had killed a lithium cell by over-discharging it.

The missing piece was P23. The stock firmware drove it, my firmware left it low, and I had written it off as "role unclear". The test that settled it was simple: with the charger attached, flip P23 every 20 seconds and log the battery voltage. With P23 high the cell climbed from 3366 to 3745 mV; with it low it sagged, and the low-phase baseline crept up from 3195 to 3465 to 3591 mV. P23 is the fast-charge enable. Low gives only a trickle, which is why a drained cell sat at 3.2 V with my firmware.

The stock code also showed it is the firmware's job to stop the charge: it drops P23 periodically to measure the battery at rest, and leaves it low once the resting voltage reaches 4088 mV. So my charge_poll() does exactly that, every 10 seconds: pause charging, wait 300 ms, measure, decide. The cell went from 3.71 to 4.09 V in about 25 minutes. As a bonus, P15 (charger detect) only reads correctly while P23 is low, because on this board P15 follows P23 when no charger is attached. A charge-enable pin that also corrupts the charger-detect pin is the kind of thing you only learn from the firmware that was designed around it.

BLE, and an address of all zeros

Amir's SDK includes the BLE host stack as source, and the stock firmware gave me the memory configuration it used: one connection, three TX and three RX packets per event, an 8 KB heap. The first advertising build came up, and neither my phone nor my laptop could see it.

It was advertising with the address 00:00:00:00:00:00. The SDK's init code points the controller at a variable for the device address and expects the app to fill it in; nothing did. The real MAC is stored in flash at 0x4000 (00:D0:42:91:02:00), so the BLE library now copies it from there, with a fixed fallback if the sector is blank. After that the watch showed up as "LP715".

The next problem was connections dropping within seconds. The cause was my own debug logging: at the higher log level the SDK prints from inside the link layer's interrupt handler, and a printf over UART at 115200 baud there blocks for 2 to 3 ms per connection event, long enough for the link to miss its timing and time out. Turning the log level back down fixed it.

Then came a small GATT service: the standard Battery Service, plus my own service with a control characteristic (buzz the motor, switch the LED, set the backlight) and an event characteristic that notifies taps, long presses and shakes. To drive it I wrote a Web Bluetooth page, a single HTML file served from localhost, which works in Chrome and Edge and needs no app.

The BLE control example: connection state, battery, the last command from the web page (buzz) and the last event sent to it (shake).
The web remote. One HTML file, no app, no install.

Sleep, and a reset nobody could explain

Everything so far ran with the CPU awake all the time, which is how the battery died that night. The PHY6222 SDK has a power manager that sleeps between events, and turning it on made the watch reboot about 1.2 seconds after it started advertising.

I chased that one for a long time and did not solve it, so here is the honest version. At 48 MHz, every wake-up from sleep resets the chip near the end of the SDK's wake-up routine. Debug counters in the always-on registers (the only memory that survives a warm reset) narrowed it to a window after the timer and radio interrupts are re-enabled, and the exact spot moved as code was added, which smells like an interrupt arriving before something is ready. The SDK's fault handler never ran.

At 16 MHz from the crystal, sleep works. The only remaining hang there was the display: the SPI block loses its registers during sleep, so the first screen update after a wake-up waited forever for an SPI status bit that would never change. The power manager lets drivers register a wake-up handler, and the display driver now uses it to set up SPI again. Output pins need the same care: GPIO retention, or every pin floats while the chip sleeps. The watch runs at 16 MHz now. A full-screen fill takes 64 ms instead of 19 ms at 48 MHz, which is irrelevant for a clock and annoying for anything animated.

Wireless updates, borrowed from the stock firmware

Every update so far went through enamelled wires on test pads. The bootloader from the Ghidra detour fixed that.

It is the Phyplus SDK's own OTA bootloader, unmodified. I checked that the hard way: the app table stores a CRC16 for each of its eleven partitions, and all eleven matched the SDK's CRC16 implementation computed over the stock dump. So the update protocol is documented in source in the same SDK I was building with, and the stock bootloader would accept my firmware if I spoke that protocol.

Instead of writing a bootloader, I kept theirs. The plan:

Updating over BLE through the stock bootloader
  1. 01
    Build the app where the bootloader expects it
    An OTA=1 build option links the app at flash 0x11020000 instead of the bootloader's spot, through a linker script generated from the SDK's one at build time.
  2. 02
    Ask the running app to reboot into the updater
    Command 04 on my control characteristic writes 2 to an always-on register (0x4000f034) and resets. The bootloader reads that register at boot and stays in update mode instead of starting the app.
  3. 03
    Find the updater, which is a different device
    The bootloader advertises under its own address with no name, only Phyplus manufacturer data (company ID 0x0504). Clients find it by that.
  4. 04
    Send the partitions
    Start, partition info, data, a CRC per partition, then reboot. Flash partitions are written where they run; RAM partitions go into the app bank and the bootloader copies them into RAM at every boot, exactly like the stock app.

A Python client (ble_ota.py, using bleak) does it in about 45 seconds from a laptop. The web page does it in 20 to 30 seconds from Chrome. The web version needed two compromises. A browser cannot read the negotiated MTU, so the client sends 20-byte packets, which fit any MTU, and asks for one acknowledgement per partition. And Chrome on macOS loses write-without-response packets when they are sent back to back; the bootloader gives up on a partition after one second without data and answers with error 0x68. Pausing for 20 ms after every four packets fixed it, which is a very unscientific constant that has worked every time since.

There was one more lesson, from a later session. The OTA tool once connected to a device with Phyplus manufacturer data, called itself ready to update, and got refused on the first write. It was not my watch. It was the Morden band from the shelf, an LP716, which advertises the same company ID, with a name. The stock bootloader advertises with no name, so the tools now ask for both. Nothing was written to it, and it is next in line anyway.

Finally, a watch

With sleep, BLE and wireless updates working, a basic watch app was the obvious next step. It shows the time in large digits, the date, the battery and the BLE state. The phone sets the time over BLE (the web page sends it on every connect), the screen turns off eight seconds after the last tap, and a tap or a shake wakes it.

The watch app on the charger: 83% with a plus for charging, the blue dot for advertising, and 13:34 set from the phone 73 minutes earlier.

The real power saving was not the screen. Even with sleep enabled, the app's update function had been running every 10 ms, which wakes the chip a hundred times a second. Now it runs that often only while the screen is on, the motor runs, or a tap or a shake has just happened. With the screen off, it runs once every 30 seconds (every 100 ms on the charger, for the charging logic), and the touch key, the tilt switch and the charger-detect pin wake the chip through GPIO interrupts. The SDK's GPIO driver already arms every interrupt pin as a sleep wake-up source, so most of this was deciding when to slow down, not wiring anything new.

Two bugs from that day are worth recording. The screen turned off about 120 ms after every tap, because the update function took a timestamp at the start, the tap handler stored a newer one, and the unsigned difference wrapped into a huge number that looked like a timeout. And the clock digits came out on separate lines, because the graphics library wraps text when the cursor passes the screen width minus the font's line height, which at double size is 22 pixels on an 80-pixel screen.

The backlight that glowed in its sleep

The last bug of the project is the one I understand least. With the screen off, the backlight still glowed faintly, but only while the chip slept. So instead of guessing, I ran a few experiments:

Test

Backlight with screen off

Same firmware, sleep disabled

Dark

Awake, P16 and P17 left floating

Dark

Awake, P01 and P02 left floating

Dark

Sleeping, pins driven high with pull-ups added

Dim

Sleeping, P16 and P17 kept as GPIOs instead of crystal pins

Dim

Sleeping, pins released as inputs with pull-ups

Dark

The suspect I liked most was P16 and P17. Those are the 32 kHz crystal pins, used here for the backlight because the board has no 32 kHz crystal, and the link layer code clears a "software control" bit for those two pins right before every sleep. Setting it again from my own sleep handler did nothing. What worked was not driving the backlight pins at all while the screen is off: release them as inputs, let the pull-ups hold them high, and drive them again when the screen turns on.

My best guess is that the outputs kept through sleep glitch low for a moment at every wake-up, and the chip wakes often enough with BLE running that those glitches add up to a visible glow. I have not proven that, and I do not have a scope trace to show for it. The table is what I have.

Where it stands

The watch runs my code: display, graphics, touch key, motor, LED, tilt switch, battery and charging, BLE control from a web page, updates over the air through the stock bootloader, sleep with the screen off, and a clock. The repo has the pin map, the flash layout, the build image and the flashing procedure.

What is still open, plainly:

  • Power draw is not measured. The design sleeps; I have not put a meter in series with the battery to say how long it lasts. Until then, "power saving" is a claim.
  • The 48 MHz wake-up reset is unexplained. 16 MHz works, so I stopped digging.
  • Clock accuracy is barely tested. Time is kept from the system tick, driven by the internal RC oscillator, because the crystal pins are busy being a backlight. The photo above was taken 73 minutes after setting the time and still matches to the minute, which rules out a gross error and says nothing about a day.
  • The stock firmware is gone from this watch. A later full restore failed at the first block after the erase, and with OTA working I chose not to fight it.
  • The USB-C soldering pen that justified buying this watch does not exist yet. Neither does the bike navigation display from last time. I am noticing a pattern.

What I would tell past me: on a chip with a working vendor firmware, read the firmware before the datasheet. The display controller, the backlight scheme, the charge-enable pin, the fake heart rate and the bootloader I ended up reusing were all sitting in the stock dump. The datasheet does not know which panel the factory fitted that month. The firmware has to.

Very little of this was invented here. Amir did the first PHY6222 watch and published the SDK and build setup everything else stands on. pvvx wrote rdwr_phy62x2.py and the PHY62x2 tooling. rbaron's M6 writeup is why I looked at this band in the first place. My part was a few enamelled wires, a reset capacitor standing in for a missing pin, and a lot of "tell me what the screen shows now."

Everything I leaned on

The firmware, the docs, the build image and the tools are in one repo:

e-labInnovations/FitPro-LP715-PHY6222

Reverse engineering and custom firmware development for the FitPro LP715 Smartwatch, which uses the PHY6222 SoC.

C 0 0

The previous watch, for comparison: Hacking a ₹249 Smartwatch: Custom Firmware on TLSR8232.

The people who did the hard parts

Another
Another@amir1387aht

A random unemployed dude

192.168.1.1 Another
20 24
Victor
Victor@pvvx

electronic equipment developer

Russian, Saint Petersburg t.me/pvvx_developments
55 642
rbaron
rbaron@rbaron
rbaron.net
83 273
Mohammed Ashad

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
// related

More from this category