← Writeups

Writing firmware for a watch that had none published: reverse engineering the Ollee Watch One

25 Aug 2026 reverse-engineeringstm32bluetooth-lefirmwarecasioolleeota

TL;DR: The Ollee Watch One replaces the movement in a Casio F-91W with an STM32WB55 board. Nothing about it is documented. Working from the vendor’s own Android app and the firmware image shipped inside it, I recovered the BLE protocol — including two commands that appear in no public catalogue — the LCD pin map, the segment map and the character generator, then wrote a firmware from scratch that booted and kept time. Then I flashed a build whose interrupt vectors pointed at an infinite loop, and put the watch into a state it cannot be recovered from without opening the case. Both halves are worth writing down.

The device

The F-91W has sold in absurd numbers since 1989 and has a small industry of people replacing its guts. Sensor Watch swaps in a SAM L22; Pluto an MSP430. The Ollee Watch One is the one with a radio: an STM32WB55, which is a dual-core part — a Cortex-M4 running the application and a Cortex-M0+ running ST’s Bluetooth stack — so it can talk to a phone while keeping the original glass.

What it will not do is anything its app does not offer. That was the starting point: I wanted phone notifications on the wrist, and the app has no such feature.

The protocol is a nameplate

There is a community effort here already. FreeOllee sends a single command over the watch’s Nordic UART service, and Gadgetbridge has a driver. Both write to target 0x2F, and the immediate disappointment is that whatever you write there is invisible until you hold a button: 0x2F is the watch’s Name tag, a nameplate you summon by holding ALARM on the clock face. It was never a notification channel.

Gadgetbridge works around this by overwriting the weekday register with a count, because the weekday row is part of the always-on clock face. Clever, and it caps out at a number.

The vendor app is a Flutter application, which is more helpful than it sounds. Dart’s AOT snapshots keep their symbols, so blutter recovers class and method names from libapp.so. That produced the whole command namespace — oap_set_alarm_req, oap_set_face_req, oap_set_config_register_req, and two that mattered:

That last line is the one that matters. Everything else this watch exposes only changes what you see after you press a button. (0,0,1) is the only way to make it do something unprompted — which is the difference between an indicator and a notification. Set a colour with 0x33, land on the clock face, then press LIGHT remotely, and the watch flashes on your wrist in a colour of your choosing.

Two ordering constraints, both found the hard way. LIGHT only works from the clock face, so pressing it while the name tag is up does nothing. And the name tag is an overlay, not a face: hold-MODE jumps to the clock face from any face but cannot escape the tag, and only a single MODE press leaves it. Without that, the first notification flashes and every one after it is silent, because each alert assumed it was starting from the clock face and the previous one had left the watch elsewhere.

The firmware documents its own display

The app ships the watch’s firmware in its assets, unencrypted — a plain Cortex-M image with a valid vector table. That is a gift twice over: it is a known-good image to restore, and it is a description of the hardware.

Static analysis said the board drives the LCD through the STM32WB’s hardware segment-LCD controller — no SPI, no driver chip. I had initially concluded the opposite, that it must be bit-banged over GPIO, on the grounds that there was no SPI and a lot of GPIO traffic. That was wrong and the peripheral base address was sitting in the binary the whole time. It matters a great deal: the hardware generates the multiplexed, DC-balanced waveform, so there is no timing to get wrong and no way to destroy the glass with a stuck bias.

Disassembling the LCD init gave the pin map directly — four HAL_GPIO_Init calls, 28 pins, alternate function 11:

VLCD   PC3
COM0-2 PA8, PA9, PA10
SEG    21 pins across ports A/B/C, plus PC10-12 muxed to SEG28-30

Three commons and twenty-four segments is twenty-seven lines, which is exactly the F-91W panel’s pin count — corroborated by Pluto’s KiCad footprint for the glass and by both prior projects driving it the same way.

Then the character generator, at a fixed offset, indexed by ascii - 0x20. It is worth pausing on how you know a table is really a font: the punctuation. " is 0x22 (segments b and f), ' is 0x20 (f alone), - is 0x40 (g alone). Those are not coincidences.

And it is Sensor Watch’s font. Ninety-two of ninety-five entries match byte for byte, differing only at J, { and }. Nobody independently decides that # should render a degree symbol, & a lowercase 7, and ! an L in the top half. Ollee’s display layer is derived from Joey Castillo’s MIT-licensed code.

That inference paid off immediately: if the font came from there, the segment map would be in the same layout — ten character positions, eight bytes each, (com << 6) | seg, with com == 3 meaning the segment does not physically exist at that position. Exactly one 80-byte region of the firmware parses that way under this board’s segment numbers, and it matches every documented quirk of the panel: position 1 shares b/c and e/f, position 2 shares a/d/g and has no f at all, positions 4 and 6 share a and d. Four structural predictions, four matches.

So the display was fully specified without ever touching a logic analyser.

The address that decides everything

Flashing needs a base address, and getting it wrong is the one mistake with no recovery: ST’s BLE_Ota loader occupies the first 28K, and an application written over it replaces the only thing that can flash the watch over the air with something that cannot.

The image itself settles it. A vector table stores absolute addresses, so only the correct base makes every entry land on a real function prologue — a push, or a stack adjustment. Scoring each 4K-aligned candidate that way gave 0x08007000 by +24 of 28, against −16 or worse for everything else. It also matches ST’s standard layout, which is reassuring but is not the evidence.

I wrote that check into the flashing tool, and its first version was worse than useless: it only verified that the reset vector landed somewhere inside the image, which is true across a whole range of wrong bases. 0x08010000 sailed through. A safety check that gives false confidence is worse than none, and I only caught it by deliberately feeding it a wrong answer.

Flashed, and then refused

The first firmware I wrote uploaded cleanly and the watch stayed on its boot screen. That looks exactly like a failed upload and is not.

ST’s loader will not jump to an application until it can prove the update finished. It reads a pointer at offset 0x140 of the image, then requires the word at that address to equal 0x94448A29. Miss it and your firmware is flashed perfectly and then declined, silently, forever.

0x140 is vector index 80 — immediately after a complete table — so satisfying it means two things: pad the vector table out to all 64 device interrupts rather than stopping at the core exceptions, and publish the address of a magic word placed somewhere in flash. The stock image does exactly this; its 0x140 holds a pointer into its own last four bytes.

With that, the watch booted my firmware and displayed the time. Clocks from LSE, the RTC left alone because it lives in the backup domain and had been keeping good time since before any of this, and the segment map driving real digits on forty-year-old glass.

A detail I enjoyed: the first build set the date to 1 January and the watch displayed the 24th, correctly. The date write had failed silently and what was on the glass was the stock firmware’s calendar, still ticking underneath. A right answer for the wrong reason, which is the kind that stays hidden until something depends on it.

And then I bricked it

The next step was Bluetooth, and this is the part worth reading.

The M0+ signals every reply through IPCC — by interrupt. My startup file pointed all 64 device interrupts at Default_Handler, which is b ., an infinite loop. ST keeps the IPCC handlers in their project template rather than in the middleware, so taking the transport layer alone leaves those vectors unclaimed and nothing complains at build time.

So the firmware came up, initialised the display, started the radio, and hung on the radio’s first answer. Blank screen, no Bluetooth.

That alone should have been survivable. I had built an escape hatch: ST’s loader reads its next action from the first byte of SRAM1, and writing 0x01 there before a reset brings the loader back. I put it after the risky code. It never ran.

And ST’s power-up path has no other way out. BootModeCheck jumps to any application carrying the magic keyword, unconditionally, on every reset — no button check, no timeout, no fallback. A hung application is booted again and again with nothing able to interrupt it. The board exposes no SWD test pads.

The fix, for anyone doing this on an STM32WB, is three parts and all three are needed:

  1. A watchdog, so a hang becomes a reset rather than a stall.
  2. Detecting that reset on the next boot — and this is the subtle one. A watchdog reset is not a software reset, and BootModeCheck only consults the loader mailbox after a software one. Seeing IWDGRST means the previous run hung; the answer is to set the flag and reset again, in software.
  3. Fault handlers that take the same path immediately, rather than spinning.

Armed before anything risky, cleared only once the firmware has proven itself. Mine was the other way round.

What I would tell myself

Read the vendor’s own troubleshooting page first. I spent an afternoon reconstructing the OTA protocol from third-party clients that turned out to target a different loader variant, chasing a stall that was always at exactly byte 4660. The actual cause was a five-byte start command where the real one is four — I had appended a sector count copied from a client for different hardware. One capture of the official app doing a real update would have shown me that in a minute. The same page also documents that a watch stuck on its boot screen is never bricked and that retrying the update always works, which is how the watch got recovered the first time.

A capture beats an inference. Every wrong turn came from reasoning about what the protocol probably was, from implementations that looked authoritative. The things I got right came from watching the real thing or from reading the firmware itself.

Arm the recovery path before the code that needs it. Obvious written down. I wrote a commit message describing that mailbox as “the only way back” and had already made it unreachable.

The work stands: the protocol, the pin map, the segment map, the font and the OTA sequence are all documented, and a firmware that booted and kept time. The watch is currently stuck on that hung build, waiting on a reset combination the vendor documents, and failing that on someone with an ST-Link. Which is a reasonable price for finding out how much a device will tell you about itself if you read its own firmware carefully enough.