From 99de7d0918877e1002635b24f85300dfb09acdc4 Mon Sep 17 00:00:00 2001 From: Ian Foster Date: Thu, 8 Jan 2026 14:09:28 -0800 Subject: [PATCH 1/4] update airtag example: * supports multiple airtags * prints airtag name and battery in addition to location * allows specifying account.json file as flag --- examples/airtag.py | 53 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/examples/airtag.py b/examples/airtag.py index 67b1dfe..a4be599 100644 --- a/examples/airtag.py +++ b/examples/airtag.py @@ -13,9 +13,9 @@ from _login import get_account_sync from findmy import FindMyAccessory -# Path where login session will be stored. +# Default path where login session will be stored. # This is necessary to avoid generating a new session every time we log in. -STORE_PATH = "account.json" +DEFAULT_STORE_PATH = "account.json" # URL to LOCAL anisette server. Set to None to use built-in Anisette generator instead (recommended) # IF YOU USE A PUBLIC SERVER, DO NOT COMPLAIN THAT YOU KEEP RUNNING INTO AUTHENTICATION ERRORS! @@ -30,32 +30,59 @@ ANISETTE_LIBS_PATH = "ani_libs.bin" logging.basicConfig(level=logging.INFO) +BATTERY_LEVEL = {0b00: "Full", 0b01: "Medium", 0b10: "Low", 0b11: "Very Low"} -def main(airtag_path: Path) -> int: - # Step 0: create an accessory key generator - airtag = FindMyAccessory.from_json(airtag_path) + +def get_battery_level(status: int) -> str: + """Extract battery level from status byte.""" + battery_id = (status >> 6) & 0b11 + return BATTERY_LEVEL.get(battery_id, "Unknown") + + +def get_airtag_name(airtag: FindMyAccessory, path: Path) -> str: + """Get a human-readable name for an airtag, with fallbacks.""" + if airtag.name: + return airtag.name + if airtag.identifier: + return airtag.identifier + return path.stem # filename without extension + + +def main(airtag_paths: list[Path], store_path: str) -> int: + # Step 0: create accessory key generators for all paths + airtags = [FindMyAccessory.from_json(path) for path in airtag_paths] + airtag_to_path = dict(zip(airtags, airtag_paths)) # Step 1: log into an Apple account - acc = get_account_sync(STORE_PATH, ANISETTE_SERVER, ANISETTE_LIBS_PATH) + acc = get_account_sync(store_path, ANISETTE_SERVER, ANISETTE_LIBS_PATH) print(f"Logged in as: {acc.account_name} ({acc.first_name} {acc.last_name})") # step 2: fetch reports! - location = acc.fetch_location(airtag) + locations = acc.fetch_location(airtags) # step 3: print 'em - print("Last known location:") - print(f" - {location}") + print("Last known locations:") + for airtag, location in locations.items(): + name = get_airtag_name(airtag, airtag_to_path[airtag]) + if location: + battery = get_battery_level(location.status) + print(f" - {name}: {location} (Battery: {battery})") + else: + print(f" - {name}: No location found") # step 4: save current account state to disk - acc.to_json(STORE_PATH) - airtag.to_json(airtag_path) + acc.to_json(store_path) + for airtag, path in zip(airtags, airtag_paths): + airtag.to_json(path) return 0 if __name__ == "__main__": parser = argparse.ArgumentParser() - parser.add_argument("airtag_path", type=Path) + parser.add_argument("airtag_paths", type=Path, nargs='+') + parser.add_argument("--store-path", type=str, default=DEFAULT_STORE_PATH, + help=f"Path to account session file (default: {DEFAULT_STORE_PATH})") args = parser.parse_args() - sys.exit(main(args.airtag_path)) + sys.exit(main(args.airtag_paths, args.store_path)) From 2c34335c6d7da170c6110c0f1b2673bb94fd8e5c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Thu, 8 Jan 2026 22:11:38 +0000 Subject: [PATCH 2/4] [pre-commit.ci lite] apply automatic fixes --- examples/airtag.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/examples/airtag.py b/examples/airtag.py index a4be599..37842d9 100644 --- a/examples/airtag.py +++ b/examples/airtag.py @@ -51,7 +51,7 @@ def get_airtag_name(airtag: FindMyAccessory, path: Path) -> str: def main(airtag_paths: list[Path], store_path: str) -> int: # Step 0: create accessory key generators for all paths airtags = [FindMyAccessory.from_json(path) for path in airtag_paths] - airtag_to_path = dict(zip(airtags, airtag_paths)) + airtag_to_path = dict(zip(airtags, airtag_paths, strict=False)) # Step 1: log into an Apple account acc = get_account_sync(store_path, ANISETTE_SERVER, ANISETTE_LIBS_PATH) @@ -72,7 +72,7 @@ def main(airtag_paths: list[Path], store_path: str) -> int: # step 4: save current account state to disk acc.to_json(store_path) - for airtag, path in zip(airtags, airtag_paths): + for airtag, path in zip(airtags, airtag_paths, strict=False): airtag.to_json(path) return 0 @@ -80,9 +80,13 @@ def main(airtag_paths: list[Path], store_path: str) -> int: if __name__ == "__main__": parser = argparse.ArgumentParser() - parser.add_argument("airtag_paths", type=Path, nargs='+') - parser.add_argument("--store-path", type=str, default=DEFAULT_STORE_PATH, - help=f"Path to account session file (default: {DEFAULT_STORE_PATH})") + parser.add_argument("airtag_paths", type=Path, nargs="+") + parser.add_argument( + "--store-path", + type=str, + default=DEFAULT_STORE_PATH, + help=f"Path to account session file (default: {DEFAULT_STORE_PATH})", + ) args = parser.parse_args() sys.exit(main(args.airtag_paths, args.store_path)) From 6e670c8e88ac0a4e181746226c4fd2504fd0eaca Mon Sep 17 00:00:00 2001 From: Ian Foster Date: Thu, 8 Jan 2026 14:25:53 -0800 Subject: [PATCH 3/4] fix commit hook warnings --- examples/airtag.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/examples/airtag.py b/examples/airtag.py index a4be599..bbc1dc5 100644 --- a/examples/airtag.py +++ b/examples/airtag.py @@ -12,6 +12,8 @@ from pathlib import Path from _login import get_account_sync from findmy import FindMyAccessory +from findmy.accessory import RollingKeyPairSource +from findmy.keys import HasHashedPublicKey # Default path where login session will be stored. # This is necessary to avoid generating a new session every time we log in. @@ -39,19 +41,20 @@ def get_battery_level(status: int) -> str: return BATTERY_LEVEL.get(battery_id, "Unknown") -def get_airtag_name(airtag: FindMyAccessory, path: Path) -> str: +def get_airtag_name(airtag: HasHashedPublicKey | RollingKeyPairSource, path: Path) -> str: """Get a human-readable name for an airtag, with fallbacks.""" - if airtag.name: - return airtag.name - if airtag.identifier: - return airtag.identifier + if isinstance(airtag, FindMyAccessory): + if airtag.name: + return airtag.name + if airtag.identifier: + return airtag.identifier return path.stem # filename without extension def main(airtag_paths: list[Path], store_path: str) -> int: # Step 0: create accessory key generators for all paths airtags = [FindMyAccessory.from_json(path) for path in airtag_paths] - airtag_to_path = dict(zip(airtags, airtag_paths)) + airtag_to_path: dict[HasHashedPublicKey | RollingKeyPairSource, Path] = dict(zip(airtags, airtag_paths)) # Step 1: log into an Apple account acc = get_account_sync(store_path, ANISETTE_SERVER, ANISETTE_LIBS_PATH) @@ -62,8 +65,9 @@ def main(airtag_paths: list[Path], store_path: str) -> int: # step 3: print 'em print("Last known locations:") - for airtag, location in locations.items(): - name = get_airtag_name(airtag, airtag_to_path[airtag]) + for airtag, path in airtag_to_path.items(): + location = locations.get(airtag) # type: ignore[union-attr] + name = get_airtag_name(airtag, path) if location: battery = get_battery_level(location.status) print(f" - {name}: {location} (Battery: {battery})") From d9fd6eebb8fbd094d2f61f10c64029451ec0aa86 Mon Sep 17 00:00:00 2001 From: Ian Foster Date: Thu, 8 Jan 2026 14:33:52 -0800 Subject: [PATCH 4/4] fix TC001 --- examples/airtag.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/examples/airtag.py b/examples/airtag.py index 8cf5387..606604f 100644 --- a/examples/airtag.py +++ b/examples/airtag.py @@ -8,12 +8,15 @@ import argparse import logging import sys from pathlib import Path +from typing import TYPE_CHECKING from _login import get_account_sync from findmy import FindMyAccessory -from findmy.accessory import RollingKeyPairSource -from findmy.keys import HasHashedPublicKey + +if TYPE_CHECKING: + from findmy.accessory import RollingKeyPairSource + from findmy.keys import HasHashedPublicKey # Default path where login session will be stored. # This is necessary to avoid generating a new session every time we log in.