See the locations of your OpenHaystack trackers - on the desktop, and on Android.
| where | what you run | needs a server? |
|---|---|---|
| macOS / Linux desktop | this repo (./run.sh) |
no |
| Android | OpenTagViewer + tools/export_opentagviewer.py |
no |
| iOS | nothing viable - see docs/LLD.md §7 | - |
Both apps talk to Apple directly with their own copy of the keys. Nothing runs in the background, and there is no service to keep alive.
Design notes: docs/PRD.md (why we did not write a mobile app), docs/LLD.md (how the Android path works end to end).
./run.shIf a trackers.json exists next to app.py, the UI seeds its tracker list from
it on first load, so the map is populated without pasting anything. Otherwise
click Trackers and paste the configuration:
[
{
"name": "microbit",
"key_id": "DIf2Od7NcEfYHsFVQTC/xTUFecr3J8B0KoPfJHsXRQM=",
"advertisement_key": "f9dQjCafB68gi9ZKLH/iQsN9tScDX+zXF4BfZGDqoyA=",
"private_key": "7b1MPYzazUCpDzTUDO9RM2h+2VytFGe0Sdua8A=="
}
]Then tracker positions has to be displayed on the map.
Seeding is deliberately limited to the local (eel) backend. The REST backend
never ships private keys to a browser it does not control.
The web UI never asks for a password. Authentication is a one-time Apple ID login done in a terminal - see sign in to Apple (once) below. Do that before the first run, or the UI will just tell you to.
Existing trackers keep working: the private_key values you already flashed are
unchanged, so no re-flashing is required by this migration.
There is no Android app in this repo and there is not going to be one. Instead
we convert trackers.json into an import bundle for
OpenTagViewer (MIT), which
runs the whole Find My stack on the phone - no server, no Mac, no tunnel. It
already supports self-generated OpenHaystack tags, so no upstream changes are
needed. The reasoning is in docs/PRD.md.
Verified working on a real device with real tags.
The bundle is written by OpenTagViewer's own exporter rather than a
reimplementation here, because the format has details you would not guess. That
code cannot be pip installed (upstream sets package = false), so point the
tool at a clone:
git clone --depth 1 https://github.com/parawanderer/OpenTagViewer.git /tmp/otv
pipenv run python tools/export_opentagviewer.py trackers.json \
--exporter-path /tmp/otv/python \
-o ~/openhaystack-tags.zipSet $OPENHAYSTACK_OTV_EXPORTER to that python/ directory to skip
--exporter-path every time.
| argument | what it does |
|---|---|
trackers |
tracker list to convert. Default trackers.json. |
--exporter-path PATH |
OpenTagViewer's python/ directory. Defaults to $OPENHAYSTACK_OTV_EXPORTER. |
-o, --output |
where to write the bundle. Default opentagviewer-import.zip. |
What comes out - one zip, a few hundred bytes:
openhaystack-tags.zip
├── OPENTAGVIEWER.yml # format version 0.0.3
└── CustomAccessories/
├── openhaystack-<id>.json # one per tracker
└── ...
Each accessory is {type: "custom_rolling_key_accessory", identifier, name, private_keys: [...]}. Import the zip, not the JSON - upstream's importer
reads it through ZipInputStream and there is no single-file path. The manifest
is mandatory: its version is what tells the importer the bundle may contain
self-generated tags.
The file is created mode 0600 via os.open, with the mode applied at creation
time rather than chmod-ed afterwards, so there is never a moment where a file
full of private keys is world-readable.
Download the APK from the releases page (~103 MB, universal) and tap it; Android will ask you to allow installs from that source. Or over USB, with Developer options -> USB debugging enabled:
adb install ~/Downloads/OpenTagViewer-1.1.1.apk
adb push ~/openhaystack-tags.zip /sdcard/Download/Android only - there is no desktop build of OpenTagViewer. On the desktop you use this repo.
- Open the app and sign in with your Apple ID. Prefer the trusted-device 2FA code; the SMS path has an open upstream bug (#236).
- First launch downloads Apple's ADI libraries (~11 MB, over the network, from Apple's own CDN). They are never bundled into the APK.
- Import
openhaystack-tags.zip. Your tags appear on the map.
Then delete the bundle from both the phone and the computer. It contains private keys in the clear, and once imported it has no further use.
rm ~/openhaystack-tags.zip
adb shell rm /sdcard/Download/openhaystack-tags.zipA tag with no recent reports shows up empty. That means nobody's iPhone has been
near it inside the window - it is not an import failure. Confirm with
tools/scan.py (see scanning over Bluetooth),
which needs no Apple account at all.
Both apps read the same keys independently, so importing changes nothing on the desktop side.
Locations come from Apple's Find My network via
FindMy.py (FindMy==0.10.2, imported as
findmy). It needs an authenticated Apple ID session. Anisette data is produced
locally by FindMy.py's LocalAnisetteProvider - there is no Docker container and
no third-party anisette server involved.
Run this once, interactively:
pipenv run python tools/findmy_login.pyIt prompts for your Apple ID, then the password (read with getpass: not echoed,
not logged, not written anywhere), then walks you through 2FA (trusted-device code
or SMS). On success it prints Signed in as ... and saves the session.
usage: tools/findmy_login.py [-h] [--account-file ACCOUNT_FILE] [--force]
[--new-device-identity] [--apple-id APPLE_ID]
| argument | what it does |
|---|---|
--apple-id APPLE_ID |
Apple ID (email). Prompted for when omitted. The password is always prompted for. |
--account-file ACCOUNT_FILE |
Where to store the session. Default: $FINDMY_ACCOUNT_FILE, else $XDG_CONFIG_HOME/openhaystack-web/account.json, else ~/.config/openhaystack-web/account.json. |
--force |
Log in again even when a valid session already exists. Without it the script detects the existing session, prints Nothing to do. and exits 0. It reuses the stored device identity, so it does not register another device. |
--new-device-identity |
Throws the stored device identity away and mints a new one, which makes Apple register another device on your Apple ID. This is the expensive action that causes Account limit reached. Only for the case where Apple has blacklisted the current identity, and only after cleaning up at https://account.apple.com/account/manage/section/devices. |
--2fa-method N |
Pick the 2FA method up front, by its number in the printed list, instead of being asked. With one method available it is chosen automatically either way. |
--code-file PATH |
Read the 6-digit code from a file instead of the terminal, polling until it appears. The file is deleted as soon as it is read, so a code cannot be replayed. For unattended runs. |
Apple registers a device per uid/devid pair it sees at the final
com.apple.mobileme step. FindMy.py invents both at random for every fresh
AppleAccount, so an interrupted or rejected login used to burn a device slot
and then throw the identity away - each retry made the problem worse until
Apple answered com.apple.mobileme login failed with status 1: Account limit reached.
The login tool now writes the device identity to the session file even when
the login fails (identity only: no username, no password, login state
LOGGED_OUT), and every later attempt - including --force - restores and
reuses it. Retrying costs zero extra device slots.
If you do hit Account limit reached, the tool prints the cleanup steps: go to
https://account.apple.com/account/manage/section/devices and remove the
phantom entries. They appear as a "MacBook Pro" running macOS 13.4.1
with a blank/zero serial number, because that is the identity FindMy.py sends
(<MacBookPro18,3> <Mac OS X;13.4.1;22F8> with X-Apple-I-SRL-NO: "0", from
findmy/reports/anisette.py). Leave your real devices alone.
These are real constraints of Apple's GSA ("grandslam") authentication, not conservative guesses:
- App-specific passwords do NOT work. GSA predates them. You must use the real Apple ID password plus interactive two-factor authentication. There is no headless/unattended way to do this first login.
- Every login with a new device identity registers another device on your
Apple ID. These accumulate, and an Apple ID with too many of them starts
refusing sign-ins (
Account limit reached). Log in once and reuse the saved session; that is whytools/findmy_login.pyrefuses to re-login unless you pass--force. Retries are safe: the device identity is saved even when a login fails and is reused by every later attempt, including--force. Only--new-device-identitycosts you a device slot. - Brand-new Apple IDs are sometimes rejected with an "account score not high enough" error. Nothing in this repo can work around that; use an established Apple ID.
| path | what |
|---|---|
~/.config/openhaystack-web/account.json |
the Apple account session or just the saved device identity, written mode 0600 in a 0700 directory |
~/.config/openhaystack-web/anisette-libs.bin |
cached anisette libraries, so later starts load locally instead of re-downloading |
Overrides: $FINDMY_ACCOUNT_FILE for the session file, $FINDMY_ANISETTE_LIBS
for the libs cache; $XDG_CONFIG_HOME is honoured for the directory.
account.json is a live Apple credential - treat it like ~/.ssh/id_rsa. It
is gitignored and must never be committed. If it is lost or corrupt, the app says
so and you re-run the login tool.
It contains your Apple ID password in plaintext. That is not this repo's
choice: findmy's AppleAccount.to_json() serializes account.password as-is,
and the library needs it to re-authenticate. This is why the file is only ever
written through the atomic 0600 writer in lib/findmy_backend.py, inside a 0700
directory. Do not copy it to a backup, a shared drive, or a paste bin.
The file has two shapes, distinguished by login.state:
login.state |
meaning | contains a password? |
|---|---|---|
3 (LOGGED_IN) |
a usable session; the app runs | yes |
0 (LOGGED_OUT) |
device identity only, saved after a failed login | no (account.username/password are null) |
has_account() / load_account() only accept LOGGED_IN. An identity-only
file still raises AccountNotConfiguredError telling you to run
tools/findmy_login.py, and /api/v1/health keeps reporting
account_configured: false - the app never mistakes a kept identity for a
session.
brew install python3
python3 -m pip install pipenv
pipenv syncPython 3.10 or newer is required (FindMy==0.10.2 is pinned to
python_version >= '3.10' and python_version < '3.15'); CI uses 3.12.
pipenv sync installs exactly what Pipfile.lock pins (with hash verification) -
eel is pinned to ==0.18.2 and FindMy to ==0.10.2, so don't replace it with a
plain pipenv install unless you intend to move those pins.
./run.sh does this for you on the first run.
Run the app:
pipenv run python3 app.pyopenocd is only needed if you are going to flash a MCU:
brew install openocdpipenv run python -m unittest discover -s tests74 tests, no network and no Apple credentials required (the Find My calls are
mocked). Nothing in tests/ imports objc or touches the macOS keychain, so the
suite runs on Linux as well as macOS - CI runs it on ubuntu-latest.
Two of them are skipped unless $OPENHAYSTACK_OTV_EXPORTER points at a
OpenTagViewer clone: they assert the real bundle contents, which needs upstream's
exporter. Everything else about the export tool is tested without it.
pipenv run ./tools/keygen.pyGenerates a new airtag keys, use an advertisement_key for the next ste
{
"key_id": "DIf2Od7NcEfYHsFVQTC/xTUFecr3J8B0KoPfJHsXRQM=",
"advertisement_key": "f9dQjCafB68gi9ZKLH/iQsN9tScDX+zXF4BfZGDqoyA=",
"private_key": "7b1MPYzazUCpDzTUDO9RM2h+2VytFGe0Sdua8A=="
}pipenv run ./tools/flash.py --advertisement-key=$KEYWait for ** Programming Finished **.
It should output like this
Open On-Chip Debugger 0.12.0
Licensed under GNU GPL v2
For bug reports, read
http://openocd.org/doc/doxygen/bugs.html
WARNING: interface/stlink-v2.cfg is deprecated, please switch to interface/stlink.cfg
Info : auto-selecting first available session transport "hla_swd". To override use 'transport select <transport>'.
Info : The selected transport took over low-level target control. The results might differ compared to plain JTAG/SWD
Info : clock speed 1000 kHz
Info : STLINK V2J25S4 (API v2) VID:PID 0483:3748
Info : Target voltage: 3.234442
Info : [nrf51.cpu] Cortex-M0 r0p0 processor detected
Info : [nrf51.cpu] target has 4 breakpoints, 2 watchpoints
Info : starting gdb server for nrf51.cpu on 3333
Info : Listening on port 3333 for gdb connections
[nrf51.cpu] halted due to debug-request, current mode: Handler HardFault
xPSR: 0x41000003 pc: 0x00000c7a msp: 0x20003fd0
Info : nRF51822-QFAA(build code: H0) 256kB Flash, 16kB RAM
Info : Mass erase completed.
Info : A reset or power cycle is required if the flash was protected before.
[nrf51.cpu] halted due to debug-request, current mode: Thread
xPSR: 0xc1000000 pc: 0xfffffffe msp: 0xfffffffc
** Programming Started **
Warn : Adding extra erase range, 0x0000374c .. 0x000037ff
** Programming Finished **
** Verify Started **
** Verified OK **
[nrf51.cpu] halted due to debug-request, current mode: Thread
xPSR: 0xc1000000 pc: 0x00000c3c msp: 0x20004000
** Programming Started **
Warn : Adding extra erase range, 0x0000374c .. 0x000037ff
** Programming Finished **
To get locations of the tracker Put keys in array into "trackers.json" as in examble below It needs time to advertise the tracker by the nearby iPhones, wait about 10m
pipenv run ./tools/locations.py # reads ./trackers.json
pipenv run ./tools/locations.py trackers.json # or pass the path
pipenv run ./tools/locations.py --config trackers.json
pipenv run ./tools/locations.py trackers.json --hours 6
pipenv run ./tools/locations.py trackers.json --format raw| argument | default | what it does |
|---|---|---|
CONFIG (positional, optional) |
trackers.json |
path to the trackers JSON |
--config CONFIG |
trackers.json |
same thing; passing both forms exits with code 2 |
--hours HOURS |
24 |
how far back to look for reports, must be > 0 |
--format {json,raw} |
json |
json prints {name: [{lat, lng, accuracy, reported_at}]}, raw prints the same mapping as a python repr |
tools/scan.py listens for tags broadcasting in radio range of this machine.
It needs no Apple ID, no iCloud and no network, so it is the way to check that a
tag is powered and advertising before blaming the Find My lookup.
pipenv run ./tools/scan.py # reads ./trackers.json, scans 30s
pipenv run ./tools/scan.py trackers.json --seconds 90
pipenv run ./tools/scan.py --all # also list other Find My devicesIt reports presence and rough distance (from RSSI), not coordinates. Range is
roughly 10-50 m. A tag that is separated from its owner broadcasts its full
advertisement key and can be matched against trackers.json by name; a tag that
still considers itself near its owner truncates that key and is only counted as
an unidentifiable Find My device. On macOS the terminal needs Bluetooth
permission (System Settings -> Privacy & Security -> Bluetooth).
The default output is JSON; --format raw prints the python repr of the same
{name: [...]} mapping. The config file may be a single JSON object instead of a
list - it is wrapped into a one-element list.
trackers.json holds tracker private keys and is gitignored - don't commit it.
Validation lives in lib/trackers.py and is shared by the eel app and the REST
backend.
| field | required | rules |
|---|---|---|
private_key |
yes | base64 P-224 private key. Everything else is derived from it (findmy.KeyPair.from_b64). An unparseable value is rejected by index, never echoing the key material. |
key_id |
no | derived from private_key as KeyPair.hashed_adv_key_b64. If you do supply it and it disagrees with the private key, the app raises a loud ValueError rather than silently querying a different tag. |
advertisement_key |
no | advisory; checked against KeyPair.adv_key_b64 the same way when present. |
name |
no | falls back to the derived key_id. It is the key of the results object, so unnamed trackers would otherwise all collide. |
Keypairs you flashed before the FindMy.py migration are still correct:
KeyPair.from_b64(private_key).adv_key_b64 and .hashed_adv_key_b64 reproduce
exactly the advertisement_key and key_id already on your tags. You do not
need to re-flash anything.
trackers.json example
[
{
"name": "My tracker",
"key_id": "DIf2Od7NcEfYHsFVQTC/xTUFecr3J8B0KoPfJHsXRQM=",
"advertisement_key": "f9dQjCafB68gi9ZKLH/iQsN9tScDX+zXF4BfZGDqoyA=",
"private_key": "7b1MPYzazUCpDzTUDO9RM2h+2VytFGe0Sdua8A=="
}
]--hours (and the hours argument of lib.trackers.get_tracker_locations,
default 24) bounds how far back reports are returned. FindMy.py 0.10.2 has no
server-side time window, so Apple is asked for the full history for each key and
the cutoff is applied client side on LocationReport.timestamp; results are
returned sorted oldest-first. tools/locations.py rejects a value <= 0 with exit
code 2; in the library a None or non-positive value means "no filtering".
The firmware defines the status LED on GPIO P0.17. Connect your LED (with a suitable series resistor) to that pin, or change the STATUS_LED_PIN definition in firmware/src/main.c if you want to use a different pin
app.py (eel) talks to python directly. If that's not available the web UI falls back
to a REST backend, backend/app.py (sanic):
pipenv run python backend/app.pyNo credentials are passed on the command line. The server restores the same
~/.config/openhaystack-web/account.json session that tools/findmy_login.py
wrote, once per process, and reuses that one AppleAccount for every request -
re-provisioning anisette on each request is what gets an Apple ID rate-limited.
A missing or broken session does not stop the server from starting: it logs a warning and retries lazily, so a login performed while the server is running is picked up without a restart.
Endpoints:
| endpoint | what |
|---|---|
POST /api/v1/locations |
body {"trackers": [...]}, returns {name: [{lat, lng, accuracy, reported_at}]} over a fixed 24 hour window |
GET /api/v1/health |
always 200; account_configured / account_session_loaded tell you whether anyone has logged in yet |
Status codes from POST /api/v1/locations: 400 for a malformed body or an
invalid/inconsistent tracker list, 503 when no Apple session is configured or the
saved one is unusable (run the login tool; no restart needed), 502 when the
upstream Find My lookup fails. Error bodies are deliberately generic - the detail
goes to the server log, because the underlying exceptions embed the account file
path and therefore the local username.
| env var | default | meaning |
|---|---|---|
BIND_HOST (or HOST) |
127.0.0.1 |
address to bind |
PORT |
8000 |
port to bind |
ALLOWED_ORIGINS |
http://localhost:<port>,http://127.0.0.1:<port> (and :8000) |
comma separated CORS origins |
FINDMY_ACCOUNT_FILE |
$XDG_CONFIG_HOME/openhaystack-web/account.json |
session file to restore |
FINDMY_ANISETTE_LIBS |
next to the account file | anisette libs cache |
BIND_HOST to anything else publishes your trackers to the network (the server logs a
warning when you do).
| path | mode | what |
|---|---|---|
~/.config/openhaystack-web/account.json |
0600 in a 0700 dir |
Apple ID session from tools/findmy_login.py. A live credential. Gitignored; never commit it. Override with $FINDMY_ACCOUNT_FILE. |
~/.config/openhaystack-web/anisette-libs.bin |
0600 in a 0700 dir |
cached anisette libraries, avoids a download on every start. Override with $FINDMY_ANISETTE_LIBS. |
./trackers.json |
- | tracker private keys. Gitignored; never commit it. |
./opentagviewer-import.zip |
0600 |
Android import bundle from tools/export_opentagviewer.py, named by -o. Contains private keys. Delete it after importing. Gitignored. |
$XDG_CONFIG_HOME is honoured for the config directory. Writes are atomic
(mkstemp -> chmod 0600 -> fsync -> os.replace), so an interrupted write can
never leave a half-written or world-readable credential.
There is no longer any macOS keychain, iCloud token cache, or 12-hour expiry: the
Apple session is reused until Apple invalidates it, at which point the app tells
you to re-run tools/findmy_login.py.



