Skip to content

Commit 688369b

Browse files
committed
Extract rssi/device type, fix crash, and move device printing
1 parent c0707dc commit 688369b

File tree

2 files changed

+149
-45
lines changed

2 files changed

+149
-45
lines changed

examples/scanner.py

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -16,27 +16,6 @@
1616
logging.basicConfig(level=logging.INFO)
1717

1818

19-
def _print_nearby(device: NearbyOfflineFindingDevice) -> None:
20-
print(f"NEARBY Device - {device.mac_address}")
21-
print(f" Status byte: {device.status:x}")
22-
print(" Extra data:")
23-
for k, v in sorted(device.additional_data.items()):
24-
print(f" {k:20}: {v}")
25-
print()
26-
27-
28-
def _print_separated(device: SeparatedOfflineFindingDevice) -> None:
29-
print(f"SEPARATED Device - {device.mac_address}")
30-
print(f" Public key: {device.adv_key_b64}")
31-
print(f" Lookup key: {device.hashed_adv_key_b64}")
32-
print(f" Status byte: {device.status:x}")
33-
print(f" Hint byte: {device.hint:x}")
34-
print(" Extra data:")
35-
for k, v in sorted(device.additional_data.items()):
36-
print(f" {k:20}: {v}")
37-
print()
38-
39-
4019
async def scan(check_key: KeyPair | FindMyAccessory | None = None) -> bool:
4120
scanner = await OfflineFindingScanner.create()
4221

@@ -46,10 +25,8 @@ async def scan(check_key: KeyPair | FindMyAccessory | None = None) -> bool:
4625
scan_device = None
4726

4827
async for device in scanner.scan_for(10, extend_timeout=True):
49-
if isinstance(device, NearbyOfflineFindingDevice):
50-
_print_nearby(device)
51-
elif isinstance(device, SeparatedOfflineFindingDevice):
52-
_print_separated(device)
28+
if isinstance(device, (SeparatedOfflineFindingDevice, NearbyOfflineFindingDevice)):
29+
device.print_device()
5330
else:
5431
print(f"Unknown device: {device}")
5532
print()

findmy/scanner/scanner.py

Lines changed: 147 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@
2323

2424
logger = logging.getLogger(__name__)
2525

26+
APPLE_DEVICE_TYPE = {
27+
0: "Apple Device",
28+
1: "AirTag",
29+
2: "Licensed 3rd Party Find My Device",
30+
3: "AirPods",
31+
}
32+
33+
BATTERY_LEVEL = {0: "Full", 1: "Medium", 2: "Low", 3: "Very Low"}
34+
2635

2736
class OfflineFindingDevice(ABC):
2837
"""Device discoverable through Apple's bluetooth-based Offline Finding protocol."""
@@ -35,12 +44,14 @@ def __init__(
3544
mac_bytes: bytes,
3645
status_byte: int,
3746
detected_at: datetime,
47+
rssi: int | None = None,
3848
additional_data: dict[Any, Any] | None = None,
3949
) -> None:
4050
"""Instantiate an OfflineFindingDevice."""
4151
self._mac_bytes: bytes = mac_bytes
4252
self._status: int = status_byte
4353
self._detected_at: datetime = detected_at
54+
self._rssi: int | None = rssi
4455
self._additional_data: dict[Any, Any] = additional_data or {}
4556

4657
@property
@@ -59,24 +70,47 @@ def detected_at(self) -> datetime:
5970
"""Timezone-aware datetime of when the device was detected."""
6071
return self._detected_at
6172

73+
@property
74+
def rssi(self) -> int | None:
75+
"""Received Signal Strength Indicator (RSSI) value."""
76+
return self._rssi
77+
6278
@property
6379
def additional_data(self) -> dict[Any, Any]:
6480
"""Any additional data. No guarantees about the contents of this dictionary."""
6581
return self._additional_data
6682

83+
@property
84+
def device_type(self) -> str:
85+
"""Get the device type from status byte."""
86+
type_id = (self.status >> 4) & 0b00000011
87+
return APPLE_DEVICE_TYPE.get(type_id, "Unknown")
88+
89+
@property
90+
def battery_level(self) -> str:
91+
"""Get the battery level from status byte."""
92+
battery_id = (self.status >> 6) & 0b00000011
93+
return BATTERY_LEVEL.get(battery_id, "Unknown")
94+
6795
@abstractmethod
6896
def is_from(self, other_device: HasPublicKey | RollingKeyPairSource) -> bool:
6997
"""Check whether the OF device's identity originates from a specific key source."""
7098
raise NotImplementedError
7199

100+
@abstractmethod
101+
def print_device(self) -> None:
102+
"""Print human-readable information about the device."""
103+
raise NotImplementedError
104+
72105
@classmethod
73106
@abstractmethod
74107
def from_payload(
75108
cls,
76109
mac_address: str,
77110
payload: bytes,
78111
detected_at: datetime,
79-
additional_data: dict[Any, Any] | None,
112+
rssi: int | None = None,
113+
additional_data: dict[Any, Any] | None = None,
80114
) -> OfflineFindingDevice | None:
81115
"""Get a NearbyOfflineFindingDevice object from an OF message payload."""
82116
raise NotImplementedError
@@ -87,6 +121,7 @@ def from_ble_payload(
87121
mac_address: str,
88122
ble_payload: bytes,
89123
detected_at: datetime | None = None,
124+
rssi: int | None = None,
90125
additional_data: dict[Any, Any] | None = None,
91126
) -> OfflineFindingDevice | None:
92127
"""Get a NearbyOfflineFindingDevice object from a BLE packet payload."""
@@ -97,6 +132,7 @@ def from_ble_payload(
97132
logger.debug("Unsupported OF type: %s", ble_payload[0])
98133
return None
99134

135+
# Differentiate between Nearby and Separated advertisements by payload length
100136
device_type = next(
101137
(
102138
dev
@@ -113,6 +149,7 @@ def from_ble_payload(
113149
mac_address,
114150
ble_payload[cls.OF_HEADER_SIZE :],
115151
detected_at or datetime.now().astimezone(),
152+
rssi,
116153
additional_data,
117154
)
118155

@@ -131,21 +168,27 @@ def __hash__(self) -> int:
131168
class NearbyOfflineFindingDevice(OfflineFindingDevice):
132169
"""Offline-Finding device in nearby state."""
133170

134-
OF_PAYLOAD_LEN = 0x02 # 2
171+
OF_PAYLOAD_LEN = 0x02 # 2 bytes (status, 2 bits of public key/mac address)
135172

136-
def __init__(
173+
def __init__( # noqa: PLR0913
137174
self,
138175
mac_bytes: bytes,
139176
status_byte: int,
140177
first_adv_key_bytes: bytes,
141178
detected_at: datetime,
179+
rssi: int | None = None,
142180
additional_data: dict[Any, Any] | None = None,
143181
) -> None:
144182
"""Instantiate a NearbyOfflineFindingDevice."""
145-
super().__init__(mac_bytes, status_byte, detected_at, additional_data)
146-
183+
super().__init__(mac_bytes, status_byte, detected_at, rssi, additional_data)
184+
# When nearby, only the first 6 bytes of the public key are transmitted
147185
self._first_adv_key_bytes: bytes = first_adv_key_bytes
148186

187+
@property
188+
def adv_key_bytes(self) -> bytes:
189+
"""Although not a full public key, still identifies device like one."""
190+
return self._first_adv_key_bytes
191+
149192
@override
150193
def is_from(self, other_device: HasPublicKey | RollingKeyPairSource) -> bool:
151194
"""Check whether the OF device's identity originates from a specific key source."""
@@ -179,14 +222,15 @@ def from_payload(
179222
mac_address: str,
180223
payload: bytes,
181224
detected_at: datetime,
225+
rssi: int | None = None,
182226
additional_data: dict[Any, Any] | None = None,
183227
) -> NearbyOfflineFindingDevice | None:
184228
"""Get a NearbyOfflineFindingDevice object from an OF message payload."""
185229
if len(payload) != cls.OF_PAYLOAD_LEN:
186230
logger.error(
187-
"Invalid OF data length: %s instead of %s",
231+
"Invalid OF data length for NearbyOfflineFindingDevice: %s instead of %s",
188232
len(payload),
189-
payload[1],
233+
cls.OF_PAYLOAD_LEN,
190234
)
191235

192236
mac_bytes = bytes.fromhex(mac_address.replace(":", "").replace("-", ""))
@@ -203,14 +247,27 @@ def from_payload(
203247
status_byte,
204248
partial_pubkey,
205249
detected_at,
250+
rssi,
206251
additional_data,
207252
)
208253

254+
@override
255+
def print_device(self) -> None:
256+
"""Print human-readable information about the device."""
257+
logger.info("Nearby %s - %s", self.device_type, self.mac_address)
258+
logger.info(" Status byte: 0x%x", self.status)
259+
logger.info(" Battery lvl: %s", self.battery_level)
260+
logger.info(" RSSI: %s", self.rssi)
261+
logger.info(" Extra data:")
262+
for k, v in sorted(self.additional_data.items()):
263+
logger.info(" %s: %s", f"{k:20}", v)
264+
logger.info("\n")
265+
209266

210267
class SeparatedOfflineFindingDevice(OfflineFindingDevice, HasPublicKey):
211268
"""Offline-Finding device in separated state."""
212269

213-
OF_PAYLOAD_LEN = 0x19 # 25
270+
OF_PAYLOAD_LEN = 0x19 # 25 bytes (status, pubkey(22), first 2 bits of MAC/pubkey, hint)
214271

215272
def __init__( # noqa: PLR0913
216273
self,
@@ -219,11 +276,11 @@ def __init__( # noqa: PLR0913
219276
public_key: bytes,
220277
hint: int,
221278
detected_at: datetime,
279+
rssi: int | None = None,
222280
additional_data: dict[Any, Any] | None = None,
223281
) -> None:
224282
"""Initialize a :meth:`SeparatedOfflineFindingDevice`."""
225-
super().__init__(mac_bytes, status, detected_at, additional_data)
226-
283+
super().__init__(mac_bytes, status, detected_at, rssi, additional_data)
227284
self._public_key: bytes = public_key
228285
self._hint: int = hint
229286

@@ -271,14 +328,15 @@ def from_payload(
271328
mac_address: str,
272329
payload: bytes,
273330
detected_at: datetime,
331+
rssi: int | None = None,
274332
additional_data: dict[Any, Any] | None = None,
275333
) -> SeparatedOfflineFindingDevice | None:
276334
"""Get a SeparatedOfflineFindingDevice object from an OF message payload."""
277335
if len(payload) != cls.OF_PAYLOAD_LEN:
278336
logger.error(
279-
"Invalid OF data length: %s instead of %s",
337+
"Invalid OF data length for SeparatedOfflineFindingDevice: %s instead of %s",
280338
len(payload),
281-
payload[1],
339+
cls.OF_PAYLOAD_LEN,
282340
)
283341
return None
284342

@@ -301,9 +359,25 @@ def from_payload(
301359
pubkey,
302360
hint,
303361
detected_at,
362+
rssi,
304363
additional_data,
305364
)
306365

366+
@override
367+
def print_device(self) -> None:
368+
"""Print human-readable information about the device."""
369+
logger.info("Separated %s - %s", self.device_type, self.mac_address)
370+
logger.info(" Public key: %s", self.adv_key_b64)
371+
logger.info(" Lookup key: %s", self.hashed_adv_key_b64)
372+
logger.info(" Status byte: 0x%x", self.status)
373+
logger.info(" Battery lvl: %s", self.battery_level)
374+
logger.info(" Hint byte: 0x%x", self.hint)
375+
logger.info(" RSSI: %s", self.rssi)
376+
logger.info(" Extra data:")
377+
for k, v in sorted(self.additional_data.items()):
378+
logger.info(" %s: %s", f"{k:20}", v)
379+
logger.info("\n")
380+
307381
@override
308382
def __repr__(self) -> str:
309383
"""Human-readable string representation of an OfflineFindingDevice."""
@@ -340,6 +414,38 @@ def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
340414

341415
self._scanner_count: int = 0
342416

417+
def print_scanning_results(self, seen_devices: dict[str, list[OfflineFindingDevice]]) -> None:
418+
"""Print summary of each device seen."""
419+
logger.info("================ RESULTS =========================")
420+
for mac, devices in seen_devices.items():
421+
avg_rssi = sum(d.rssi for d in devices if d.rssi is not None) / len(
422+
[d for d in devices if d.rssi is not None]
423+
)
424+
logger.info("Device %s seen %d times, average RSSI: %.1f", mac, len(devices), avg_rssi)
425+
of_type = (
426+
"nearby" if isinstance(devices[0], NearbyOfflineFindingDevice) else "separated"
427+
)
428+
logger.info(
429+
" %s with %s battery (%s state)",
430+
devices[0].device_type,
431+
devices[0].battery_level,
432+
of_type,
433+
)
434+
435+
if isinstance(devices[0], SeparatedOfflineFindingDevice):
436+
logger.info(" Public key: %s", devices[0].adv_key_b64)
437+
logger.info(" Lookup key: %s", devices[0].hashed_adv_key_b64)
438+
439+
logger.info("===============================================")
440+
441+
device_type_counts: dict[str, int] = {}
442+
for devs in seen_devices.values():
443+
dev_type = devs[0].device_type
444+
device_type_counts[dev_type] = device_type_counts.get(dev_type, 0) + 1
445+
446+
for dev_type, count in device_type_counts.items():
447+
logger.info("Total %s: %d", dev_type, count)
448+
343449
@classmethod
344450
async def create(cls) -> OfflineFindingScanner:
345451
"""Create an instance of the scanner."""
@@ -365,8 +471,10 @@ async def _scan_callback(
365471
device: BLEDevice,
366472
data: AdvertisementData,
367473
) -> None:
368-
self._device_fut.set_result((device, data))
369-
self._device_fut = self._loop.create_future()
474+
# Ensure that only one waiting coroutine is notified
475+
if not self._device_fut.done():
476+
self._device_fut.set_result((device, data))
477+
self._device_fut = self._loop.create_future()
370478

371479
async def _wait_for_device(self, timeout: float) -> OfflineFindingDevice | None:
372480
device, data = await asyncio.wait_for(self._device_fut, timeout=timeout)
@@ -377,6 +485,11 @@ async def _wait_for_device(self, timeout: float) -> OfflineFindingDevice | None:
377485

378486
detected_at = datetime.now().astimezone()
379487

488+
# Extract RSSI if it exists
489+
rssi = None
490+
if data.rssi is not None:
491+
rssi = data.rssi
492+
380493
try:
381494
additional_data = device.details.get("props", {})
382495
except AttributeError:
@@ -387,6 +500,7 @@ async def _wait_for_device(self, timeout: float) -> OfflineFindingDevice | None:
387500
device.address,
388501
apple_data,
389502
detected_at,
503+
rssi,
390504
additional_data,
391505
)
392506

@@ -405,21 +519,34 @@ async def scan_for(
405519
await self._start_scan()
406520

407521
stop_at = time.time() + timeout
408-
devices_seen: set[OfflineFindingDevice] = set()
522+
# Map MAC address to device objects (nearby doesn't send entire pubkey)
523+
# This avoids double counting when device has different status/hint byte (but same pubkey)
524+
devices_seen: dict[str, list[OfflineFindingDevice]] = {}
409525

410526
try:
411527
time_left = stop_at - time.time()
412528
while time_left > 0:
413529
device = await self._wait_for_device(time_left)
414-
if device is not None and device not in devices_seen:
415-
devices_seen.add(device)
416-
if extend_timeout:
417-
stop_at = time.time() + timeout
418-
yield device
530+
531+
if device is not None:
532+
# Check if we have already seen this device
533+
new_device = device.mac_address not in devices_seen
534+
535+
devices_seen[device.mac_address] = [
536+
*devices_seen.get(device.mac_address, []),
537+
device,
538+
]
539+
540+
if new_device:
541+
if extend_timeout:
542+
stop_at = time.time() + timeout
543+
yield device
419544

420545
time_left = stop_at - time.time()
546+
421547
except asyncio.TimeoutError: # timeout reached
422548
self._device_fut = self._loop.create_future()
549+
self.print_scanning_results(devices_seen)
423550
return
424551
finally:
425552
await self._stop_scan()

0 commit comments

Comments
 (0)