Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Knowing if a particular AirTag presents in the scan results? #46

Closed
thisiscam opened this issue Jul 2, 2024 · 5 comments · Fixed by #50
Closed

Knowing if a particular AirTag presents in the scan results? #46

thisiscam opened this issue Jul 2, 2024 · 5 comments · Fixed by #50
Labels
enhancement New feature or request

Comments

@thisiscam
Copy link

Hi!

Since #41, I am pretty convinced that close-to-realtime location using the crowdsourcing FindMy approach won't fit my application (tracking my dog). I was thinking of using an AirTag as a BLE tracker just so that I can know in almost real-time that the AirTag is at home.

Is there a way to know if a particular AirTag is present in the scan results? I see that the scanner gives the MAC address and the public key of any AirTag. But, given an AirTag, how do I know either of these two values for that particular AirTag? The public key is rotating, am I right? So maybe there is a way to predict this public key using the seed plist?

I have tried:

>>> acc = findmy.accessory.FindMyAccessory.from_plist(open("my_airtag.plist", "rb"))
>>> acc.keys_at(datetime.now(timezone.utc))

But the key it returns doesn't seem to match with any of the scanner results. I also tried passing in my current timezone (EST) to keys_at, but still no luck.

Any guidance or suggestions would be greatly appreciated!

@malmeloo
Copy link
Owner

How did you compare the keys? keys_at returns a set of KeyPairs, did you compare those public keys against the one you got from the scan?

It's also possible there is some timing inaccuracy, you could try using keys_between instead and pass two timezones, let's say -24h and +24h of the timestamp of the scan.

@thisiscam
Copy link
Author

How did you compare the keys? keys_at returns a set of KeyPairs, did you compare those public keys against the one you got from the scan?

Yes, that's what I tried.

It's also possible there is some timing inaccuracy, you could try using keys_between instead and pass two timezones, let's say -24h and +24h of the timestamp of the scan.

Thanks! I am running this script:

import datetime, pytz
import findmy
import findmy.scanner

import asyncio
import logging

logging.basicConfig(level=logging.INFO)

acc = findmy.accessory.FindMyAccessory.from_plist(open("mykey.plist", "rb")) # obtained from decoding FindMy
now = datetime.datetime.now(pytz.timezone("EST"))
keys = acc.keys_between(now - datetime.timedelta(hours=24), now + datetime.timedelta(hours=24))



async def scan() -> None:
    scanner = await findmy.scanner.OfflineFindingScanner.create()

    print("Scanning for FindMy-devices...")
    print()

    async for device in scanner.scan_for(1000, extend_timeout=True):
        print(f"Device - {device.mac_address}")
        print(f"  Public key:   {device.adv_key_b64}")
        print(f"  Lookup key:   {device.hashed_adv_key_b64}")
        print(f"  Status byte:  {device.status:x}")
        print(f"  Hint byte:    {device.hint:x}")
        print("  Extra data:")
        for k, v in sorted(device.additional_data.items()):
            print(f"    {k:20}: {v}")
        print()
        if any((device.adv_key_b64 == k.adv_key_b64) for k in keys):
            print("Found!!!!")
            break

if __name__ == "__main__":
    asyncio.run(scan())

Doesn't seem to hit Found! after 1000 seconds. I do see two unmatched results, unclear if those are my AirTags or someone else's nearby. Maybe my AirTags are not broadcasting because my other devices are around?

@malmeloo
Copy link
Owner

Ohhh yes indeed then that will not work. As you guessed, Airtags do not broadcast when they're near an owner device. Well, they do, but it's a very small payload that is only used to indicate to the owner device that it is still nearby (afaik). I think the scanner currently skips those, because there is no way to retrieve the full identity of the tag from those reports.

It should still be possible to verify whether a given public key is nearby though. If you just scan for nearby bluetooth low energy MAC addresses, the last 4 bytes of your tag should be identical of byte 1 - 4 of the active public key (0-indexed). That's not currently implemented in the scanner, but I'll take it as a feature request 🙂

@malmeloo malmeloo added the enhancement New feature or request label Jul 11, 2024
@thisiscam
Copy link
Author

thisiscam commented Jul 11, 2024

Ohhh yes indeed then that will not work. As you guessed, Airtags do not broadcast when they're near an owner device. Well, they do, but it's a very small payload that is only used to indicate to the owner device that it is still nearby (afaik). I think the scanner currently skips those, because there is no way to retrieve the full identity of the tag from those reports.

It should still be possible to verify whether a given public key is nearby though. If you just scan for nearby bluetooth low energy MAC addresses, the last 4 bytes of your tag should be identical of byte 1 - 4 of the active public key (0-indexed). That's not currently implemented in the scanner, but I'll take it as a feature request 🙂

Fabulous!

Here's my attempt:

import datetime, pytz
import findmy
import findmy.scanner
import findmy.scanner.scanner

import asyncio
import logging
import time

logging.basicConfig(level=logging.INFO)

acc = findmy.accessory.FindMyAccessory.from_plist(open("myairtag.plist", "rb"))
now = datetime.datetime.now(pytz.timezone("EST"))
keys = acc.keys_between(now - datetime.timedelta(hours=24), now + datetime.timedelta(hours=24))



async def scan() -> None:
    scanner = await findmy.scanner.OfflineFindingScanner.create()

    print("Scanning for FindMy-devices...")
    print()

    async for device in scan_for(scanner, 1000, extend_timeout=True):
        print(f"Device - {device.mac_address}")
        print(f"  Public key:   {device.adv_key_b64}")
        print(f"  Lookup key:   {device.hashed_adv_key_b64}")
        print(f"  Status byte:  {device.status:x}")
        print(f"  Hint byte:    {device.hint:x}")
        print("  Extra data:")
        for k, v in sorted(device.additional_data.items()):
            print(f"    {k:20}: {v}")
        print()
        if any((device.adv_key_b64 == k.adv_key_b64) for k in keys):
            print("Found!!!!")
            break

def has_consecutive_four_byte_match(bytes1, bytes2):
    length1 = len(bytes1)
    length2 = len(bytes2)
    
    if length1 < 4 or length2 < 4:
        return False
    
    for i in range(length1 - 3):
        four_bytes1 = bytes1[i:i+4]
        for j in range(length2 - 3):
            four_bytes2 = bytes2[j:j+4]
            if four_bytes1 == four_bytes2:
                return True
                
    return False

async def _wait_for_device(self, timeout: float):
        device, data = await asyncio.wait_for(self._device_fut, timeout=timeout)

        mac_bytes = bytes(int(part, 16) for part in device.address.split(':'))
        for k in keys:
            if has_consecutive_four_byte_match(mac_bytes, k.adv_key_bytes):
                print("Found MAC!!!")
                print(device)
                print(data)
                print(mac_bytes)
                print(k.adv_key_bytes)


        apple_data = data.manufacturer_data.get(self.BLE_COMPANY_APPLE, b"")
        if not apple_data:
            return None

        try:
            additional_data = device.details.get("props", {})
        except AttributeError:
            # Likely Windows host, where details is a '_RawAdvData' object.
            # See: https://github.com/malmeloo/FindMy.py/issues/24
            additional_data = {}
        return  findmy.scanner.scanner.OfflineFindingDevice.from_payload(device.address, apple_data, additional_data)


async def scan_for(
        self,
        timeout: float = 10,
        *,
        extend_timeout: bool = False,
    ):
        """
        Scan for `OfflineFindingDevice`s for up to `timeout` seconds.

        If `extend_timeout` is set, the timer will be extended
        by `timeout` seconds every time a new device is discovered.
        """
        await self._start_scan()

        stop_at = time.time() + timeout
        devices_seen = set()

        try:
            time_left = stop_at - time.time()
            while time_left > 0:
                device = await _wait_for_device(self, time_left)
                if device is not None and device not in devices_seen:
                    devices_seen.add(device)
                    if extend_timeout:
                        stop_at = time.time() + timeout
                    yield device

                time_left = stop_at - time.time()
        except (asyncio.CancelledError, asyncio.TimeoutError):  # timeout reached
            return
        finally:
            await self._stop_scan()

if __name__ == "__main__":
    asyncio.run(scan())

Note that I tried what you described on the indexing but that didn't give me any match. So I used that bruteforce has_consecutive_four_byte_match function, and I got a match!
Here's the match pretty printed:

MAC: 'e5:29:d7:92:57:65'
adv_key_bytes: 'a5:29:d7:92:57:65:ac:2a:9b:0f:3c:5a:eb:f4:9e:08:2b:78:6f:d7:db:39:f6:08:76:36:a4:f5'

It seems like MAC[-5:] == adv_key_bytes[1:6]? Five matching bytes?

@malmeloo
Copy link
Owner

5 matching bytes indeed, I think I had a brain fart there 😅. Actually, the first 6 bytes of the public key are encoded in the MAC address, with the exception of the two most significant bits of the first byte. Those two are always 0b11 due to BLE requirements, so Apple put them in the two MSBs of the 3rd byte of the advertisement payload instead.

I've started implementing 'nearby' status device detection in #50. I'll extend that to allow checking whether a device corresponds to a specific keypair or findmy-accessory.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants