Blog

  • Exploring, Flashing, and Programming the TB-03F — My Journey into DIY Airtags

    Working with unfamiliar hardware can be both frustrating and exhilarating. This post is about how I tinkered my way through flashing the TB-03F chip — a super-cheap, low-power SoC, to create a functional clone of Apple’s AirTag, using entirely open-source tools that are usable on any platform.

    The Spark: Discovering the TB-03F

    It all began when I discovered OpenHaystack, an open-source project that enables tracking devices using Apple’s Find My network. I quickly came across Aaron Christophel’s YouTube video showcasing how to build a €2 AirTag clone using the TB-03F module.

    While the idea was solid, I found the firmware very limiting — it was closed source, lacked rotating keys, had no battery reporting, and could only be flashed via a Chrome based browser tool. That wouldn’t do. The hardware, though, had potential.

    Getting the Hardware

    I ordered some TB03F modules and, a bit later, a NodeMCU-style TB03F development board for easier debugging.

    While waiting for the dev board, I got started with what I had.


    The First Hurdle: Flashing

    Flashing these chips is not as straightforward as with something like an ESP8266. Initially, I tried using various USB-to-TTL adapters – FTDI, CH340, CP2102 – you name it. I even ordered a variety pack of cheap adapters. Turns out, the USB-C ones are ALL garbage – no exceptions.
    In the end, none of the adapters I tried worked with the
    flashing method provided by the SDK.

    Eventually, I pulled the JavaScript flasher code from Christophel’s site and ported it to Python using pyserial. That way, I had full control over the flashing process and could run it offline.


    How the Flasher Works (Quick Deep Dive)

    The Telink chip doesn’t use standard serial upload protocols like esptool or dfu-util. Instead, it requires bit-banged commands over UART to simulate Single-Wire Serial (SWS) communication. Here’s a simplified breakdown:

    • Reset the MCU using DTR/RTS pins.
    • Send SWS commands to write to internal registers.
    • Unlock the flash memory for writing.
    • Erase sectors and write 256-byte blocks.
    • Soft reset the chip after flashing.

    The flasher does all this using custom logic to generate precise byte sequences. One critical part is the sws_wr_addr function, which translates a command or data payload into the exact 10-byte-per-byte waveform the TB03F expects.

    Details on SWS

    SWS is Telink’s ultra-minimal debug protocol. It transmits each bit as a full byte – essentially a bit-banged waveform – allowing even simple UART peripherals to communicate with the chip if you control DTR/RTS.


    Writing Better Firmware: Key Rotation and Battery Status

    The original firmware worked but lacked two major features:

    1. Rotating keys, which periodically changes the BLE address/advertisement data for privacy.
    2. Battery level reporting, to display a green/yellow/red battery indicator icon in the OpenHaystack Web UI.

    I started implementing key rotation but found that the rotation only happened once – then never again. It turns out, the index counter needs to be stored in retention RAM, which persists across deep sleep cycles. Once I fixed that, rotation worked perfectly using a soft timer.


    Debugging With the Dev Board

    Once the dev board finally arrived, I plugged it straight into my PC and stole a basic at_print debug function which I pulled from one of the Telink SDK examples.

    After adding the line

    DOWNLOAD_PORT := /dev/ttyUSB0

    to the Makefile, I was able to use

    make flash

    to upload firmware directly to the development board, and

    make monitor

    to view console output. This streamlined the workflow significantly and made testing changes much more efficient.

    Even more important: This gave me real-time feedback from the battery voltage reading. Invaluable info while tinkering with the battery advertisement feature


    Reading Battery Voltage Without Extra Wires

    One goal was to minimize physical modifications to the modules – ideally, I wanted to track battery life without needing a separate ADC wire.

    After digging into slow-loading chinese docs and repos, I discovered that PB7 can be used as a pseudo-ADC:

    1. Set PB7 high.
    2. Immediately read its voltage — this reflects VBAT.

    This works because PB7 is internally connected in such a way that its GPIO HIGH voltage mirrors the battery level. So, I could get a working readout with only VCC and GND connected – no extra wires or soldering! Of course, the actual implementation is a little more tricky – and stolen from the SDK as well. 🙂


    Broadcasting the Battery Level

    Next, I figured out how to add the voltage reading to the BLE advertising packet. Apple reserves the 7th hex byte in its implementation for battery level. I looked into how Biemster did this for nRF52-based devices and mimicked it:

    • Measure voltage at boot and every 14 days.
    • Write it to retention RAM.
    • Inject the value into the BLE advertisement payload.

    And it worked! I saw my device show up in OpenHaystack with the correct battery icon.

    To set the battery level in the BLE advertisement correctly, the 7th byte (index 6) is masked and updated using predefined bit patterns. The upper two bits of that byte are reserved for battery status, so we use a bitmask to preserve the rest of the data while only modifying those two bits. Specifically, the line:

    tbl_advData[6] = (tbl_advData[6] & ~STATUS_FLAG_BATTERY_MASK) | stored_battery_status;

    first clears the battery bits using the inverse of STATUS_FLAG_BATTERY_MASK (which is 0b11000000), and then sets them to one of the defined levels like STATUS_FLAG_LOW_BATTERY or STATUS_FLAG_FULL_BATTERY, depending on the voltage reading. This approach ensures only the relevant bits are changed without affecting other parts of the advertisement byte.


    Wrapping It All Up

    In the final version, I removed all debug logs, generated a fresh set of keys, flashed the firmware using my Python tool, and… success again!

    The device started advertising immediately and showed up with a green battery symbol in the OpenHaystack app just minutes later.

    The firmware and all the tools can be found in my repo:

    https://github.com/stefexec/TB-03F-OpenHaystack-Firmware/

    Final Thoughts

    This project taught me a lot — not just about the TB-03F chip, but also about low-level firmware flashing, BLE broadcasting, and managing power on embedded systems. If you’re into microcontrollers, privacy-respecting tracking, or just want to tinker, the TB-03F is a fun chip to explore.

    Sources:
    https://docs.ai-thinker.com/_media/ble/docs/tb-03f_product_specification_en_v1.0.pdf

    https://github.com/Ai-Thinker-Open/Telink_825X_SDK/

    https://docs.ai-thinker.com/_media/tb-03f-kit_specification_en.pdf

    http://wiki.telink-semi.cn/doc/an/AN_17092700-E_Telink%20826x%20BLE%20SDK%20Developer%20Handbook.pdf

    https://tuprints.ulb.tu-darmstadt.de/29727/1/Alexander_Heinrich_Dissertation.pdf

    https://github.com/dchristl/macless-haystack/tree/main/firmware/nrf5x

  • Flashing a 5€ AirTag Clone with OpenHaystack Firmware (No Mac Required)

    This post walks through the process of flashing a cheap AirTag clone with OpenHaystack, setting up a macless backend using Docker, and tracking the tag via your own infrastructure – without needing a Mac or iPhone.


    Hardware Overview

    • Generic AirTag clone (~€5)
    • ESP32 NodeMCU dev board
    • Soldering iron + basic tools
    • Access to a Linux PC or similar
    • OpenMediaVault (OMV) NAS with Docker (optional, can be any Docker-capable host)

    Firmware Flashing: Step-by-Step

    1. Unboxing

    Simple generic packaging. Inside: a plastic tag. Shown on the mat are different types of keyfinders/tracking devices that also work with this method.

    2. Opening the case

    Using a pry tool or flat screwdriver, open the tag to access the PCB.

    Note: The firmware made by biemster was originally made for the
    ST17H66, but appears to work flawlessly on the ST17H65, too.

    3. Soldering to test points

    Locate and solder to TX, RX, GND, and VCC test points.

    Documentation I found online suggests that P10 is RX and P9 is TX but your module might differ.

    4. Generate keypair

    Clone the repo at

    https://github.com/stefexec/ST17H6X-OpenHaystack-Tags

    python generate_keys.py

    This will generate a private/public keypair.
    There should be 3 files in the output folder.

    <ID>.keys contains your keypair.

    You’ll flash the public key (Advertisement key) onto the tag.

    4. Prepare ESP32 for Flashing

    • Connect EN to GND on the ESP32 NodeMCU to disable its own chip.
    • Wire TX/RX to the tag: ESP TX → tag RX, ESP RX → tag TX.
    • DON’T connect VCC/GND yet!

    At first I was trying to flash it using a generic USB-C FTDI. Flashing using this method did not work at all, which is why I’ve tried using a NodeMCU as alternative. Turns out this method worked flawlessly!

    6. Flash Command

    PySerial needs to be installed, then just simply type

    ./flash_st17h66.py <advertisement_key>.

    Use the public key from the previous step as <advertisement_key>.

    Hit enter.

    7. Start Flashing

    • Power the tag by connecting VCC and GND.
    • Run the flash command.
    • If the chip isn’t detected right away:
      • Try disconnecting/reconnecting VCC until it is.
      • Swap TX/RX wires if necessary.
      • Reconnect the board to the programmer entirely

    This is how it looks like when the flash succeeded:

    8. Reassemble

    Once the firmware is successfully flashed, remove wires and snap the case shut.

    Now, for the fun part:

    Docker-Based Backend (No Mac Needed)

    I’m using Docker on my OMV NAS to host both the Anisette auth server and the macless OpenHaystack backend.

    version: '3'

    services:
    anisette:
    image: dadoum/anisette-v3-server
    container_name: anisette
    restart: always
    ports:
    - "6969:6969"
    volumes:
    - anisette-v3_data:/home/Alcoholic/.config/anisette-v3/lib/
    networks:
    - mh-network

    macless-haystack:
    image: christld/macless-haystack
    container_name: macless-haystack
    restart: unless-stopped
    ports:
    - "6176:6176"
    volumes:
    - /haystack-data:/app/endpoint/data
    networks:
    - mh-network
    stdin_open: true
    tty: true

    networks:
    mh-network:
    name: mh-network

    volumes:
    anisette-v3_data:
    mh_data:

    Expose the Backend

    I used a Cloudflare Tunnel to make the macless-haystack server publicly accessible under a custom domain including SSL.

    That domain can then be entered in the official OpenHaystack frontend:

    🔗 https://dchristl.github.io/macless-haystack/

    In here you can upload the <ID>_devices.json file you’ve generated earlier to link your Tag!

    Logging in via Docker

    Before you can register and receive data from your tag, you need to log into your Apple ID inside the container.

    1. Start the Docker containers
    2. SSH into your server and run:
    docker exec -it macless-haystack /bin/bash

    Then, even though you won’t see a prompt:

    • Type your Apple ID email and press Enter
    • You’ll then see the password prompt
    • Enter password, then go through 2FA as prompted

    Use container logs (docker logs macless-haystack) to debug if needed.

    Once authenticated, the backend is ready to go.

    Thanks to:

    https://github.com/dchristl/macless-haystack

    https://github.com/biemster/FindMy