|
8 | 8 | import logging |
9 | 9 | import sys |
10 | 10 | from pathlib import Path |
| 11 | +from typing import TYPE_CHECKING |
11 | 12 |
|
12 | 13 | from _login import get_account_sync |
13 | 14 |
|
14 | 15 | from findmy import FindMyAccessory |
15 | 16 |
|
16 | | -# Path where login session will be stored. |
| 17 | +if TYPE_CHECKING: |
| 18 | + from findmy.accessory import RollingKeyPairSource |
| 19 | + from findmy.keys import HasHashedPublicKey |
| 20 | + |
| 21 | +# Default path where login session will be stored. |
17 | 22 | # This is necessary to avoid generating a new session every time we log in. |
18 | | -STORE_PATH = "account.json" |
| 23 | +DEFAULT_STORE_PATH = "account.json" |
19 | 24 |
|
20 | 25 | # URL to LOCAL anisette server. Set to None to use built-in Anisette generator instead (recommended) |
21 | 26 | # IF YOU USE A PUBLIC SERVER, DO NOT COMPLAIN THAT YOU KEEP RUNNING INTO AUTHENTICATION ERRORS! |
|
30 | 35 |
|
31 | 36 | logging.basicConfig(level=logging.INFO) |
32 | 37 |
|
| 38 | +BATTERY_LEVEL = {0b00: "Full", 0b01: "Medium", 0b10: "Low", 0b11: "Very Low"} |
| 39 | + |
| 40 | + |
| 41 | +def get_battery_level(status: int) -> str: |
| 42 | + """Extract battery level from status byte.""" |
| 43 | + battery_id = (status >> 6) & 0b11 |
| 44 | + return BATTERY_LEVEL.get(battery_id, "Unknown") |
| 45 | + |
| 46 | + |
| 47 | +def get_airtag_name(airtag: HasHashedPublicKey | RollingKeyPairSource, path: Path) -> str: |
| 48 | + """Get a human-readable name for an airtag, with fallbacks.""" |
| 49 | + if isinstance(airtag, FindMyAccessory): |
| 50 | + if airtag.name: |
| 51 | + return airtag.name |
| 52 | + if airtag.identifier: |
| 53 | + return airtag.identifier |
| 54 | + return path.stem # filename without extension |
| 55 | + |
33 | 56 |
|
34 | | -def main(airtag_path: Path) -> int: |
35 | | - # Step 0: create an accessory key generator |
36 | | - airtag = FindMyAccessory.from_json(airtag_path) |
| 57 | +def main(airtag_paths: list[Path], store_path: str) -> int: |
| 58 | + # Step 0: create accessory key generators for all paths |
| 59 | + airtags = [FindMyAccessory.from_json(path) for path in airtag_paths] |
| 60 | + airtag_to_path: dict[HasHashedPublicKey | RollingKeyPairSource, Path] = dict( |
| 61 | + zip(airtags, airtag_paths, strict=False) |
| 62 | + ) |
37 | 63 |
|
38 | 64 | # Step 1: log into an Apple account |
39 | | - acc = get_account_sync(STORE_PATH, ANISETTE_SERVER, ANISETTE_LIBS_PATH) |
| 65 | + acc = get_account_sync(store_path, ANISETTE_SERVER, ANISETTE_LIBS_PATH) |
40 | 66 | print(f"Logged in as: {acc.account_name} ({acc.first_name} {acc.last_name})") |
41 | 67 |
|
42 | 68 | # step 2: fetch reports! |
43 | | - location = acc.fetch_location(airtag) |
| 69 | + locations = acc.fetch_location(airtags) |
44 | 70 |
|
45 | 71 | # step 3: print 'em |
46 | | - print("Last known location:") |
47 | | - print(f" - {location}") |
| 72 | + print("Last known locations:") |
| 73 | + for airtag, path in airtag_to_path.items(): |
| 74 | + location = locations.get(airtag) # type: ignore[union-attr] |
| 75 | + name = get_airtag_name(airtag, path) |
| 76 | + if location: |
| 77 | + battery = get_battery_level(location.status) |
| 78 | + print(f" - {name}: {location} (Battery: {battery})") |
| 79 | + else: |
| 80 | + print(f" - {name}: No location found") |
48 | 81 |
|
49 | 82 | # step 4: save current account state to disk |
50 | | - acc.to_json(STORE_PATH) |
51 | | - airtag.to_json(airtag_path) |
| 83 | + acc.to_json(store_path) |
| 84 | + for airtag, path in zip(airtags, airtag_paths, strict=False): |
| 85 | + airtag.to_json(path) |
52 | 86 |
|
53 | 87 | return 0 |
54 | 88 |
|
55 | 89 |
|
56 | 90 | if __name__ == "__main__": |
57 | 91 | parser = argparse.ArgumentParser() |
58 | | - parser.add_argument("airtag_path", type=Path) |
| 92 | + parser.add_argument("airtag_paths", type=Path, nargs="+") |
| 93 | + parser.add_argument( |
| 94 | + "--store-path", |
| 95 | + type=str, |
| 96 | + default=DEFAULT_STORE_PATH, |
| 97 | + help=f"Path to account session file (default: {DEFAULT_STORE_PATH})", |
| 98 | + ) |
59 | 99 | args = parser.parse_args() |
60 | 100 |
|
61 | | - sys.exit(main(args.airtag_path)) |
| 101 | + sys.exit(main(args.airtag_paths, args.store_path)) |
0 commit comments