Speeeedo
So my 'running truck' is a Japanese imported car, converted from kph to mph. As a result, the speedo is more of a general guideline that a reliable indicator of travelling speed. This is not ideal.
![]() |
| A speedo that says it's km/h, it isn't. |
It's 'closeish' and good enough to judge generally, but a more accurate display would be neat. I had intended on cobbling something together with one of the GPS modules I have, an ESP and a XIAO round display, I thought that might look pretty funky.... but... time.
However, our new car has a cool HUD that displays speed (and various other bits) so I thought that might be neat, and then discovered that you can get a "dirt cheap" GPS HUD device off the internet. Well that'll be a lot easier and quicker than making one (*hah!), although a DIY one would be rather sexy. Ordered, and several weeks later, a grey plastic bag from China arrived. 😄
I plug it in, and oh, it says "km/h" 🙁 a quick test run (delayed slightly due to be being injured and housebound) confirms it's a km/h device. Bugger. The advert definitely said "MPH", I double check and yes, it did. Ok. I try prodding around just in case there's some hidden switch or touch control (the device is a single enclosed black box, with no buttons or leads, and with just a USB power socket).
When it's powered up though it *does* have leds that light up for both "MPH" and "KM/H" briefly at first. Hmm.
More prodding and I try various tricks like rapid power on/off etc. But no. nothing. Just in case, I connect it to a USB port to see if it enumerates anything, but no, nothing.
I contact the seller to enquire, perhaps there's some trick, but no, they just confirm this is the kph version there's no toggle option, and they apologise and offer a refund. They tell me to just keep the unit. (did I mention this was *dirt* cheap?)
Well in that case... let's pop it open and have a play. After much twisting and prying (there are no screws or obvious way in), I manage to crack it open and get at the board. Ooooh there are pads for a missing switch (and a few other optional components) it looks like there's a possible variable resistor (R2) (which aligns with an unused cutout in the front of the case, and a contact (CT1), perhaps a touch switch.
Interesting... could it be that simple?
![]() |
| Unused pads. |
I add a switch, is this mph/kph?..... aaaaand, no. it doesn't appear to do anything.
Fudge. I check/test shorting out the CT1 and resistor pins in case there's anything there, but no.
But, well hello! what's this? It has an SWD debug connector! oooohh...
![]() |
| An ARM debugging connector |
I solder on a connector, and have a little prod... it seems to respond but I need to know what I'm dealing with....
![]() |
| The board with added switch and debug connector |
I de-shield the module on the left in case there's something interesting in there and note down what we have. I don't recognize anything, but we have a "BK1616" GPS module on the left, a "SM16306", ok that's an LED driver, and an "F002AW15", right it's a "Puya" PY32F002A ARM Cortex M0 chip.
I fire up openocd and start prodding, it seems it's close to an STM32 so I can read the memory. Next up, I start putting together an openocd script with the right address banks configured from the datasheet.
![]() |
| Memory layout |
transport select swd
adapter speed 4000
swd newdap puya cpu -expected-id 0x0bc11477
dap create puya.dap -chain-position puya.cpu
target create puya.cpu cortex_m -endian little -dap puya.dap
puya.cpu configure -work-area-phys 0x20000000 -work-area-size 0xc00 -work-area-backup 0
flash bank puya.flash stm32f1x 0x08000000 0x5000 0 0 puya.cpu
Firing up openocd and I can dump the flash and RAM. I take a couple of RAM snapshots to compare.
openocd -f interface/stlink.cfg -f ./puya.cfg
dump_image flash_dump.bin 0x08000000 0x5000
dump_image ram_dump.bin 0x20000000 0xC00
Looking through the flash_dump for inspiration there are very few strings, there is a block of various GPS NMEA labels but nothing else interesting/useful:
![]() |
| NMEA strings |
Time for Ghidra.... I setup the memory blocks, load the image and start decompiling. OK we have code, but nothing leaps out. I scan for possible "unit conversion" constants as hex bytes. Floating point values for kph/mph conversion are 0.621371 or
1.609344 so I look for possible integer uses (e.g. you might multiply by 1609, divide by 1000 and so on) I look for 40723 (0.621371192*2^16) and all the variants I can think of.
I come up with nothing.
OK. time to read the decompiled code, but scanning through each function (clearly very optimised compiled code) nothing looks obvious. I can see various string trimming/scanning/parsing routines, clearly reading the GPS stream and parsing them. but no obvious conversion logic.
![]() |
| Random ghidra decompile |
Oh in the meantime I see the GPIO pins are accessed via:
Port A Base Address: 0x50000000
Port B Base Address: 0x50000400
Port F Base Address: 0x50001400
I try writing and confirm (unsurprisingly), by causing the LED's to scramble, that this is the control for the LED driver, however it seems it's an I2C device and I don't feel like trying to decode that. I can find and see the code that writes to these ports, but clearly since it's I2C it's several layers down from the actual logic I'm interested in.
Stop. Hammer time. 😁
I diff my dumped RAM files.
So we have the "working" memory here... and there are the data strings from the GPS at the bottom, something that looks like code at the top, and lots of data. I start poking memory. reboot. reboot. reboot. reboot..... etc. Oooh the numbers flicker.... and so on.
![]() |
| Pokin |
I write a python script to fire data into openocd and walk the memory... writing data into 0x20000261 and I get a number increasing flicking (it resets back to zero immediately).
I write into 0x200002b7..... and... THE MPH LIGHT COMES ON AND THE KPH GOES OUT!!
I try a few other values in the region and I can get the lights to go out, but that seems to be it.
I try writing increasing numbers into the 0x20000261 address and the scaling changes based on the MPH/KPH mode.
YES! I do a little cheer. I scan the assembly for reads/writes to the x2b7 address but it doesn't seem to be writing it specifically. hmm. I take a look at the firmware again. I search for the bytes in memory around that value (00 00 6B009600) and there they are!!
It must have this as "configuration data" in the firmware that gets copied over into the RAM. Righty ho, the value is actually duplicated at the end of the firmware too, so I set all of these values to "00 01 6B" also.
How to flash this back in... openocd flashing complains that the chipid doesn't match, it seems there is an openocd fork to flash a Puya chip, but I really don't want to be mucking about with that.... googling suggest "pyocd" is used to flash py32F chips, so on it goes.... aand fails with a python dependency. sigh.
venv created and pyocd installed in there with all it's dependencies.... more googling, it seems I need the "puya pack" file for pyocd.
$ wget https://www.puyasemi.com/uploadfiles/Puya.PY32F0xx_DFP.1.2.8.pack
$ pyocd flash --pack=Puya.PY32F0xx_DFP.1.2.8.pack --target=py32f002ax5 -a 0x08000000 flash_dump2.bin
and into the flash it goes.... the device restarts... AND IT'S IN MPH!!!! We're done! 🕺
OK so this is quite a pointless report really.... it's a really cheap device, most people would get the right one, and to fix one that's wrong you need to open it up, solder debug connections on, dump out, patch and reflash the firmware. or. you could just buy a different one!
I'm mainly writing this to keep a record for what I needed to do for next time I need to do something similar!
Anyhow. It was fun tinkering with this, not sure there's any point or benefit to spending any more time looking at the code, it is after all very tightly compiled and optimised to do "just one job"
Footnote:
FOUND IT!!
DAT_08001164+3 equates to 0x200002b7 and DAT_08001160+0x30 is 0x20000260, the addresses we found above.
So the code gets metres and then either divides by 1000 to get km or (sigh) divides by 1600 to get miles-ish. 🙄 Unsurprising I couldn't find these constants before - 1. I was looking for the wrong value (1609 etc) and 2. they aren't stored as integers but calculated in the highly optimised code, for 1000 the code is:
movs r1,#0x7d
lsls r1,r1,#0x3
for 1600 it uses:
movs r1,#0x19
lsls r1,r1,#0x6
and 199 is your max speed. 🤣












