A couple of months back I lost my wallet on a private bus. Late night, walking back to my flat after boxing, and two or three minutes off the bus I realised my pocket was empty. I called the conductor immediately. Nothing. I even got the corporation cleaning head to promise he would call if it turned up. Nothing for days.
Then I went to my home town, and after Jumu'ah prayer a speed post courier showed up. I opened it, and there was my wallet, everything still in it. Some kind stranger had found it and posted it back. I called the number on the courier and thanked him properly. He had no reason to do that, and he did it anyway.
That is the good version of losing a wallet. The version where a decent human happens to find it. I did not want to depend on that version again.
So I wanted a tracker in the wallet. Then I sat on the idea and did nothing about it. Months later, for a completely unrelated reason (a bike-navigation idea, which is the subject of the previous post), I ended up flashing my own firmware onto a ₹249 smartwatch. That left me holding a BLE SoC I fully controlled, sitting in a case with a battery, for the price of a meal. Only then did the two ideas connect: the wallet problem from months back and the hackable watch now sitting on my desk. A DIY AirTag, built from a watch I already owned.
A bike navigation idea led me to reverse engineer a ₹249 smartwatch, crack its SWire debug port, and flash my own firmware on it.
The wrong shape first: a contact card
My first instinct was not even electronic. I was going to design a credit-card-sized "if found, please return" card with Gemini 3 Nano Banana Pro, print it, drop it in the wallet. Name, a QR code, done.
I ran the idea past ChatGPT, mostly for the design. The useful part of that conversation was the pushback, not the layout:
Bro, you're over-engineering this in classic Ashad style 😄. A simple passive card works fine.
It talked me out of printing my phone number directly (name plus number on a lost card is a gift to scammers) and toward a QR that opens a controlled page you can disable or re-point later. All sensible. But it also surfaced the real limitation of the whole contact-card approach: a card is passive. It only works if someone finds the wallet, cares enough to scan it, and acts. It does nothing if the wallet is sitting under a bus seat, or in a drawer in someone's house, silent.
I did not want a card that begs to be returned. I wanted to know where the wallet was. That is a tracker, not a card.
Can my chip even do this?
The video that started it was Aaron Christophel (atc1441) turning a Telink chip into a FindMy tag. He was not hacking a watch, though: he used a TB-03F, a small development module wired to a USB-to-TTL adapter and running off a coin cell. That module carries a TLSR8253. Mine was a TLSR8232, sealed inside a ₹249 watch instead of sitting on a breadboard. Close enough in the name to raise hope, different enough to worry me.
So the first thing I actually did was check whether the older chip was up to it. The honest comparison:
TLSR8232 | TLSR8253 | |
|---|---|---|
CPU | 32-bit Telink, ~32 MHz | Newer, up to 48 MHz |
SRAM | 16 KB | 48 KB |
Flash | 128 / 512 KB | 512 KB |
Bluetooth | BLE 4.2 + some 5 | BLE 5.x |
ECC hardware | No | Yes |
AES hardware | Basic | Enhanced |
The 8253 wins on paper by a mile, and everyone sensibly recommends it for "AirTag-style tracker." But the axis that actually decided it for me was: I already had the 8232 in my hand, flashed and working. A FindMy beacon is a small job. It advertises a public key and sleeps. 16 KB of SRAM and no crypto accelerator is fine when the firmware does not do any crypto on-chip: all the elliptic-curve work happens on the owner's side, not on the tag. The chip that broadcasts is deliberately dumb. So the "worse" chip was the right chip, because its one job is small and it was free.
Then I hit the detour that nearly ended the project before any firmware existed.
I do not own an iPhone or a Mac. This is the part of the DIY-AirTag story nobody mentions: the hard dependency is not the chip, it is an Apple identity that Apple's Find My network will actually talk to. The way through, which I confirmed before buying into the plan, is that the unofficial route (macless-haystack and biemster's tooling) authenticates against Apple's backend directly and can obtain a searchPartyToken, which is what actually matters, rather than depending on the iCloud web "Find Devices" page being lit up. If that token comes through, you are in.
How a FindMy tracker actually works
Before writing firmware I wanted to understand what I was broadcasting, and this is where I went a level deeper than I strictly needed to, because the design is genuinely clever.
The naive version of a tracker broadcasts one fixed public key forever. That works, and it is also a privacy disaster. A fixed identifier is a number plate. Anyone logging BLE around the city can say "the device seen in Kochi this morning is the one in Bangalore now," without ever knowing who owns it. The identifier alone is enough to build a movement history.
Apple's fix is to rotate the advertised key constantly, so an observer sees what look like unrelated devices:
8:00 AM -> Public Key A
8:15 AM -> Public Key B
8:30 AM -> Public Key C
The MAC address rotates alongside it. To a stranger, A, B and C are three different things. To the owner, they are one tag.
The interesting question is how the owner reconnects them. There are two ways to get "many advertisement keys, one owner":
Path 1, independent keypairs. Generate N random private keys d_0..d_{N-1}, each with its own public key P_i = d_i · G and its own hash. There is no relationship between them. The owner just stores all N private keys and asks Apple for all N hashes in one batch. Simple, but the owner is carrying thousands of unrelated secrets.
Path 2, a derived chain (what real AirTags do). The owner stores one master seed SK_0 and one base private key d_0, about 60 bytes total. Every epoch's key is derived from the last:
SK_i = KDF(SK_{i-1})
(u_i, v_i) = KDF(SK_i)
d_i = (d_0 · u_i + v_i) mod n
P_i = d_i · G
From 60 bytes of stored secret you can regenerate every one of the ~2880 daily keypairs on demand. It even gets forward secrecy for free: if today's SK_i leaks, an attacker can walk the chain forward but the KDF is one-way, so the past stays sealed.
Multiple public keys are not bound to one private key. They are bound to one master secret, from which many temporary keypairs are derived. That is the whole trick.
For my tag I deliberately did neither of the fancy things. My MVP broadcasts a single static key: no rotation, no chain. It is worse for privacy, and I know it, but it proves the pipeline end to end with the least possible firmware. Rotation from a precomputed table is a follow-up, not a launch requirement. The reference firmware I was porting, biemster's tlsr825x_FindMy, does not rotate either, so I was in good company.
Porting the firmware
The plan was minimal on purpose: take biemster/tlsr825x_FindMy's main.c, strip it to the bone, port it from the 825x to my 8232. No deep sleep, no rotation. Just override the MAC, pack the public key into Apple's manufacturer-specific advertisement format, and set the advertising interval.
Most of the port was chasing SDK differences between the two chips in the same family. They are close cousins, not twins. The things that bit me, all in the ble_sdk_hawk SDK:
blc_ll_initBasicMCUnow takes a MAC argument, andinitStandby_moduleis gone.MYFIFO_INIT(blt_rxfifo, blt_txfifo)is mandatory on the 8232 or nothing advertises.- Drivers are split per chip under
drivers/5316/, and the oldble.humbrella header is broken intoble_common.hplusll/ll.h. ADV_INTERVAL_2Sis not even defined in the 8232 SDK, so I set the raw value3200(in 0.625 ms units) by hand.
The key never lives in the source. I generate a keypair with biemster's generate_keys.py, which writes a .keys file, then tools/prep_fw.py patches the advertisement key straight into the compiled binary at a sentinel offset (0x6a6c). Build once, patch per key. The first clean build was a 27 KB FindMy.bin.
# generate a keypair, then patch it into the firmware binary
./generate_keys.py -n 1 # -> <hash>.keys
python3 tools/prep_fw.py tools/<hash>.keys # -> FindMy_<hash>.bin
The night it did nothing, the morning it worked
I flashed the first firmware late at night, brought up anisette-v3-server in Docker (it provides the Apple GSA tokens the query side needs), and ran request_reports.py. Nothing. No reports.
That is a bad feeling at midnight, staring at an empty query result with no way to tell which of five things is broken: the advertisement format, the MAC override, the key patching, my Apple account, or simply that no iPhone had walked past my desk yet. I went to sleep half-convinced the firmware was wrong.
Before that, though, I checked the one thing I could verify from my own desk: was the watch even transmitting? I opened nRF Connect on my phone and scanned. Nothing. For a moment I was sure the BLE advertising itself was dead. So I asked Claude to compute the BLE MAC address from my advertisement key (a FindMy beacon builds its MAC out of the key bytes, it is not random), then opened DaFit Canvas, my browser-based BLE tool from an earlier project, and scanned again. There it was: the exact MAC I expected, sitting in the device list. The beacon was alive. nRF Connect just refused to show it, and I went to sleep not knowing why.
Next morning I ran the exact same script. A handful of reports came back. Real ones, with real coordinates.
The nRF Connect mystery resolved itself the same morning: the app filters out Apple FindMy and AirTag advertisements by default, on purpose. Turn that filter off and my watch shows up in the list like anything else. So I had burned an evening convinced the firmware was dead when it had been broadcasting correctly the whole time, hidden behind a scanner's default setting.
The bigger lesson was the last item on that list: FindMy is not instant. Your tag is silent until some Apple device passes within Bluetooth range, encrypts your tag's location with your public key, and uploads it to Apple. And here is the part that surprised me. I do own an Apple device, a work MacBook, and it was sitting right there on the same desk all night. It scanned and uploaded nothing. A Mac at rest is not a relay the way an iPhone in a stranger's pocket is. What actually logged my tag was other people's phones passing my flat overnight. The firmware had been correct the whole time.
That is also the honest limitation of this whole approach, and it is worth understanding why it works at all. Apple's network relays any correctly-formatted FindMy beacon without checking whether it came from a certified accessory. Researchers found that behaviour and OpenHaystack was built on it. Real Find My accessories carry Apple's MFi authentication hardware that a hobbyist cannot buy, but the relaying iPhone never demands it, so a ₹249 watch broadcasting the right packet gets uploaded just the same. The flip side is unavoidable: no passing Apple device, no location. Put the tag somewhere no iPhone ever goes and it stays silent forever.
To sanity-check against something with a UI, I pasted the same key into macless-haystack as a new accessory. The same location came back, this time on a map. Seeing my own tag as a pin, sitting where I knew it was, off firmware I had written, was the moment it stopped being an experiment.
Building my own app: FindMyFamily
macless-haystack proved the concept, but its map view was not the product I wanted. I wanted something self-hosted, one instance per family, that a non-technical person in the house could actually use. So I planned a PWA that works on both phone and desktop, and called it FindMyFamily.
The shape:
- One Apple account links the whole app. There is an app admin who owns that link and manages everything.
- The admin adds members. Members add their own accessories and track only theirs.
- Map view on OpenStreetMap, no Apple map dependency.
Stack decisions, and why: Next.js for the app because I wanted one framework doing both the PWA frontend and the server-side Apple calls; Prisma and Postgres for storage; Leaflet and OpenStreetMap tiles instead of any paid map SDK because self-hosting the whole thing was the point; and anisette in Docker for the Apple GSA headers. The app runs on port 6177, tucked next to the other FindMy services.
The hardest part, by a wide margin, was not the UI. It was porting Apple's authentication out of Python and into TypeScript.
Porting Apple GSA login to native Node
biemster's tooling logs into Apple with a Python implementation of Apple's GSA SRP handshake (a flavour of SRP-6a), then does an iCloud mobileme delegation to get { dsid, searchPartyToken }, the pair you need to query the Find My network. I had to reproduce that exactly in Node, because Next.js is not going to shell out to Python in production.
"Exactly" is the operative word. SRP is unforgiving: if any intermediate value is off by a byte, the server just says no and gives you nothing to debug with. My approach was to diff my Node output against the Python reference at every step until the SRP proof M1 and the session key K matched bit for bit. Two quirks accounted for almost all the pain:
- Apple's SRP variant keeps a
":"separator insidex = H(s, H(":" + p))even though there is no username in that hash. Miss it and every derived value downstream is wrong. - Apple returns the SRP session cookie
cas a plist<string>, not<data>. Echo it back as<data>and the cookie corrupts and Apple answers withec=-22405. That error code cost me an evening on its own.
One more decision on the crypto side. Apple's Find My uses the P-224 curve. I first reached for @noble/curves, but its v2 had dropped P-224 support, keeping only P-256/384/521. Rather than pin an old version, I switched to Node's built-in crypto.createECDH('secp224r1'), which is the same curve and one fewer dependency. I gated the whole port behind a test that decrypts a known-good payload captured from the Python tool and asserts bit-exact equality of the decoded fields. Once that passed, the decrypt side was provably correct and I could stop second-guessing it.
After a long stretch of this, one byte at a time, the app logged into my Apple account and pulled my tag's location. From there the rest of the app was ordinary work: member management, accessory CRUD, the map, the detail sheet.
One thing I added because Apple only retains reports for about seven days: FindMyFamily stores every decoded report (and the raw encrypted payload) in Postgres, keyed by accessory and timestamp so re-ingest is idempotent. Ingest is lazy, one small Apple fetch per page visit that only asks for the window since the last one it saw, so steady-state cost stays tiny and history survives well past Apple's window. If Apple is down or the token expired, the page still renders from the database instead of going blank.
The battery saga: finding the pin nobody documented
The firmware worked and the app worked, but a real AirTag tells you when its battery is low, and mine did not. Apple reserves two bits in the advertisement's status byte for battery level, and the report data surfaces it. I wanted my watch to report its real charge.
The problem: the watch's original firmware shows battery percentage, but I do not have its source. I bought two of these watches (the silicon lottery, explained in the previous post), so I dumped the stock firmware off the spare and tried to reverse engineer the battery-reading code with Claude and Ghidra. On a stripped Telink binary, that did not pin down the voltage math. Dead end.
So we guessed from first principles. The watch runs a small 3.7 V LiPo (about 4.2 V full), which I had rewired through a switch so I could power the board from the Blue Pill's 3.3 V when the battery was off. First assumption: the TLSR8232 reads its own supply through the internal VBAT channel. Claude wrote code to measure that and print voltage and percentage on the watch's own display.
I checked it against a multimeter. Mismatch. Rewrite. Mismatch. Again, and again. After enough rounds I was fairly sure we had simply failed.
Then the question that turned it: what if the battery voltage does not go to the internal channel at all, but through an ordinary GPIO pin? That reframed the whole search. Instead of trusting a datasheet channel, we would measure reality: probe every GPIO pin's voltage under two conditions, battery connected and charger connected, and look for the pin that tracked the battery.
That found it. The battery sense is on PB1, through a 1:4 resistor divider on the PCB, not the internal VBAT channel at all. That is why every internal-channel reading had been wrong: it was measuring the regulated rail, not the cell.
Continuity and a multimeter are the truth. A datasheet channel is a hypothesis.
With the right pin known, the reading code was straightforward: adc_base_init(GPIO_PB1) for external analog input, multi-sample to kill noise (take eight samples, drop the first two and last two, average the middle four), convert raw to millivolts at the pin with (avg * 295) >> 8, then multiply by four to undo the divider and recover the true VBAT. Map 3000 to 4200 mV onto 0 to 100 percent.
The last step was making Apple's world understand it. The firmware packs the level into the status byte the advertisement carries, so the owner side can read it, and FindMyFamily renders it as a proper battery icon on the accessory detail. While I was in the power code I also switched the beacon from no-sleep to light BLE suspend between advertising bursts, which drops the average current from around 2 mA to somewhere in the 200 to 500 µA range. On a 100 mAh cell that is the difference between roughly 50 hours and 10 to 20 days. The 8232 SDK does not expose the deep-retention sleep the newer chips have, I checked by grepping the whole SDK, so light suspend is as far down as this chip goes. It helps. It was also, as the first real field test would show me, nowhere near enough.
Twelve hours
To actually carry the thing, I changed the hardware a little. I desoldered the display and the fake heart-rate LED off the board, the parts a tracker has no use for, and put the bare board back into its case. The case mattered for one specific reason: reassembled, the watch charges from an ordinary USB charger through its original contacts, so I did not have to rig up charging myself.
There is no charge indicator once the display is gone, so I charged it overnight blind and checked the level the next day. I read it back through nRF Connect, which surfaces the FindMy battery field, and it showed full. It is worth being precise about what "full" means here, because you asked the right question if you are wondering: Apple's advertisement carries only two bits of battery state, four buckets, so there is no real percentage. "100%" is just the top bucket. My firmware measures actual millivolts on PB1, but everything downstream of the advertisement collapses to one of four levels.
Then I ran the only test that counts. I dropped the watch in my bag, went to the office, went straight from there to a team dinner, came back to my room and slept. A normal day, tag along for the ride.
Next morning the tag was dead, and FindMyFamily's history stopped at the previous night. Roughly twelve hours on a full charge.
That is the honest verdict on this build. The firmware is correct, the app works, the location history is real, and none of it matters if the battery lasts twelve hours. The cause is two things stacked: a tiny watch LiPo, and a chip that cannot sleep deeply enough to nurse it. I confirmed the second one rather than assuming it. The 825x reference firmware sets DEEPSLEEP_RETENTION_ADV and calls blc_ll_recoverDeepRetention() on wake; none of that exists on the 8232 path, so the deepest state I can reach is a light suspend still burning 200 to 500 µA. Between adverts a real AirTag is nearly off. Mine is only dozing.
So as a wallet tracker, with this battery, it is not usable. Twelve hours means I would be charging it more often than I would be losing my wallet.
There are two ways out, and I have built neither yet. For the wallet, I would need a higher-capacity 3.7 V cell, and then two questions I cannot answer on paper: whether a bigger cell physically fits in a wallet, and how many days it would actually buy. That needs testing, not guessing.
The other idea is the one I am leaning toward, and it came from a bad reason. A few weeks ago a college mate posted in our private WhatsApp group that his bike had been stolen. A tracker has no battery problem if it never runs on a battery. The plan: drop a 5 V regulator on the USB charging input, wire it to the bike's own battery, and pull the tiny LiPo out entirely. The tag then lives on the bike's power, advertising forever, with no charge to lose. It is a much better home for a device whose only real flaw is that it gets hungry. I have not done it yet. It is the next thing.
What I would tell past me
Three things.
The dumb chip was the right chip. I spent real worry on TLSR8232 versus 8253, and it did not matter, because the tag's job is to broadcast and sleep. Match the chip to the job, not to the spec sheet.
The hard part of a "hardware" project was software. The beacon firmware was a weekend. The Apple GSA port, byte-aligning SRP against a Python reference until M1 matched, was where the weeks went. ec=-22405 is burned into my memory now 🫠.
And measure before you guess. Both real walls in this project, the empty midnight query and the wrong battery pin, dissolved the moment I stopped trusting an assumption and looked at what was actually happening. The reports needed a passing iPhone, not a firmware fix. The battery was on a GPIO, not the internal channel. Neither was solvable by thinking harder; both were solvable by looking.
The wallet is safe at home. But I now have a ₹249 tag I fully control, an app I host myself, and firmware I can read every line of. That is a better outcome than a printed card that says please.
Build one yourself
Everything here is open, so if you want your own ₹249 tracker, this is the whole path, backend included. You do not need an iPhone or a Mac to run it. You do need an Apple account that the Find My network will actually talk to, which is the one non-obvious catch, so it is called out below.
What you need
- A smartwatch with a Telink TLSR8232 inside. This is the gamble from the top of this post: the chip is never on the listing, so you buy on odds and open it to check. The one I used is this listing. Expect to guess, and buy from somewhere with returns.
- An STM32 Blue Pill as a SWire flasher, wired and flashed exactly as in the previous post. That post is the flashing half of this one, so I will not repeat the wiring here.
- Any always-on machine for the backend: a home server, a VPS, or just your laptop while you test. It needs Docker and Node.
1. Clone both repos
git clone https://github.com/e-labInnovations/FindMy-TLSR8232.git # firmware + host tools
git clone https://github.com/e-labInnovations/findmy-family.git # the web app
2. Run the backend: anisette plus FindMyFamily
anisette provides the Apple GSA tokens the app needs; FindMyFamily is the Next.js app that logs in and shows the map. Both run wherever you like.
cd findmy-family
npx --yes pnpm@9.15.0 install
docker compose up -d # anisette on :6969
# point this at a Postgres you already run, then create a database
psql -U postgres -c "CREATE DATABASE findmy_family;"
cp .env.example .env
# fill in .env:
# DATABASE_URL -> your Postgres connection string
# MASTER_KEY -> openssl rand -hex 32 (encrypts keys + Apple tokens at rest)
# AUTH_SECRET -> openssl rand -hex 32 (signs login sessions)
npx --yes pnpm@9.15.0 dlx prisma migrate dev --name init
npx --yes pnpm@9.15.0 dev # app on :6177
First boot lands on a setup screen. The first account you create is the admin, and the admin is the one who links the household's single Apple account under Settings.
The one hard dependency is the Apple account. It must have Find My enabled, which in practice means it has been signed into a real Apple device at least once. A brand-new web-only Apple account returns no reports, no matter how correct your firmware is. This blocked me before I had written a single line of firmware, so verify it first.
3. Generate a key for the tag
In the firmware repo's host tools:
cd FindMy-TLSR8232/tools
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
./generate_keys.py -n 1 # -> <hash>.keys (private + advertisement + hashed key)
4. Get the firmware binary
Either build it yourself (needs Docker; the Telink toolchain is x86_64-only, so the image is pinned to amd64):
cd FindMy-TLSR8232
cd sdk && docker build --platform linux/amd64 -t tlsr8232-sdk-amd64 . && cd ..
docker run --rm --platform linux/amd64 -v $(pwd):/app -w /app \
tlsr8232-sdk-amd64 bash -c 'make SDK=/opt/ble_sdk_hawk'
# -> _build/FindMy.bin
or skip the build and grab the prebuilt FindMy.bin from below.
5. Patch your key into the binary
The key is never compiled in. It is patched into the built binary at a fixed offset, so one build serves any key:
python3 tools/prep_fw.py tools/<hash>.keys # -> FindMy_<hash>.bin
6. Flash the watch
Using the Blue Pill SWire bridge from the previous post:
python tlsr82-debugger-client.py \
--serial-port /dev/ttyACM0 \
write_flash FindMy_<hash>.bin
7. Track it
Add the accessory in FindMyFamily using the generated key, and wait.
That is the whole pipeline: a ₹249 watch, two repos, one Apple account, and someone else's iPhone walking past.
The code
The beacon firmware.
The self-hosted app.
Credits
The value of this chip is the trail of people who suffered for it first, and this project is built straight on their work.
The Python GSA login and report-decrypt tooling I ported to TypeScript.
Query Apple's Find My network
The 825x beacon firmware I ported to the 8232.
FindMy firmware for the Telink 825x MCU
The no-Mac report server and map that let me sanity-check without an iPhone.
Create your own AirTag with OpenHaystack, but without the need to own an Apple device
The original research that proved any beacon could ride the Find My network.
Build your own 'AirTags' 🏷 today! Framework for tracking personal Bluetooth devices via Apple's massive Find My network.
The hardware-hacking videos that started all of it.
Discussion 0 comments
Be kind. I read everything but might take a day or two to reply.