Skip to content

Commit 3dc5fb5

Browse files
committed
Allow user to specify printing to file
1 parent 688369b commit 3dc5fb5

File tree

2 files changed

+91
-50
lines changed

2 files changed

+91
-50
lines changed

examples/scanner.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,14 @@ async def scan(check_key: KeyPair | FindMyAccessory | None = None) -> bool:
2424

2525
scan_device = None
2626

27-
async for device in scanner.scan_for(10, extend_timeout=True):
27+
scan_out_file = Path("scan_results.jsonl")
28+
# Clear previous scan results
29+
if scan_out_file.exists():
30+
scan_out_file.unlink()
31+
32+
async for device in scanner.scan_for(10, extend_timeout=True, print_summary=True):
2833
if isinstance(device, (SeparatedOfflineFindingDevice, NearbyOfflineFindingDevice)):
29-
device.print_device()
34+
device.print_device(out_file=scan_out_file)
3035
else:
3136
print(f"Unknown device: {device}")
3237
print()

findmy/scanner/scanner.py

Lines changed: 84 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import asyncio
6+
import json
67
import logging
78
import time
89
from abc import ABC, abstractmethod
@@ -17,20 +18,21 @@
1718

1819
if TYPE_CHECKING:
1920
from collections.abc import AsyncGenerator
21+
from pathlib import Path
2022

2123
from bleak.backends.device import BLEDevice
2224
from bleak.backends.scanner import AdvertisementData
2325

2426
logger = logging.getLogger(__name__)
2527

2628
APPLE_DEVICE_TYPE = {
27-
0: "Apple Device",
28-
1: "AirTag",
29-
2: "Licensed 3rd Party Find My Device",
30-
3: "AirPods",
29+
0b00: "Apple Device",
30+
0b01: "AirTag",
31+
0b10: "Licensed 3rd Party Find My Device",
32+
0b11: "AirPods",
3133
}
3234

33-
BATTERY_LEVEL = {0: "Full", 1: "Medium", 2: "Low", 3: "Very Low"}
35+
BATTERY_LEVEL = {0b00: "Full", 0b01: "Medium", 0b10: "Low", 0b11: "Very Low"}
3436

3537

3638
class OfflineFindingDevice(ABC):
@@ -98,8 +100,8 @@ def is_from(self, other_device: HasPublicKey | RollingKeyPairSource) -> bool:
98100
raise NotImplementedError
99101

100102
@abstractmethod
101-
def print_device(self) -> None:
102-
"""Print human-readable information about the device."""
103+
def print_device(self, out_file: Path | None = None) -> None:
104+
"""Print human-readable information about the device to stdout or file."""
103105
raise NotImplementedError
104106

105107
@classmethod
@@ -185,7 +187,7 @@ def __init__( # noqa: PLR0913
185187
self._first_adv_key_bytes: bytes = first_adv_key_bytes
186188

187189
@property
188-
def adv_key_bytes(self) -> bytes:
190+
def partial_adv_key(self) -> bytes:
189191
"""Although not a full public key, still identifies device like one."""
190192
return self._first_adv_key_bytes
191193

@@ -252,16 +254,33 @@ def from_payload(
252254
)
253255

254256
@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")
257+
def print_device(self, out_file: Path | None = None) -> None:
258+
"""Print human-readable information about the device to stdout or file."""
259+
# ruff: noqa: T201
260+
if out_file:
261+
data = {
262+
"mac_address": self.mac_address,
263+
"status": self.status,
264+
"device_type": self.device_type,
265+
"battery_level": self.battery_level,
266+
"rssi": self.rssi,
267+
"detected_at": self.detected_at.isoformat(),
268+
"additional_data": self.additional_data,
269+
}
270+
271+
with out_file.open("a", encoding="utf-8") as f:
272+
# Indent json for readability
273+
json.dump(data, f, indent=4)
274+
f.write("\n")
275+
else:
276+
print(f"Nearby {self.device_type} - {self.mac_address}")
277+
print(f" Status byte: 0x{self.status:x}")
278+
print(f" Battery lvl: {self.battery_level}")
279+
print(f" RSSI: {self.rssi}")
280+
print(" Extra data:")
281+
for k, v in sorted(self.additional_data.items()):
282+
print(f" {k:20}: {v}")
283+
print("\n")
265284

266285

267286
class SeparatedOfflineFindingDevice(OfflineFindingDevice, HasPublicKey):
@@ -364,19 +383,38 @@ def from_payload(
364383
)
365384

366385
@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")
386+
def print_device(self, out_file: Path | None = None) -> None:
387+
"""Print human-readable information about the device to stdout or file."""
388+
# ruff: noqa: T201
389+
if out_file:
390+
data = {
391+
"mac_address": self.mac_address,
392+
"public_key": self.adv_key_b64,
393+
"lookup_key": self.hashed_adv_key_b64,
394+
"status": self.status,
395+
"device_type": self.device_type,
396+
"battery_level": self.battery_level,
397+
"hint": self.hint,
398+
"rssi": self.rssi,
399+
"detected_at": self.detected_at.isoformat(),
400+
"additional_data": self.additional_data,
401+
}
402+
403+
with out_file.open("a", encoding="utf-8") as f:
404+
json.dump(data, f, indent=4)
405+
f.write("\n")
406+
else:
407+
print(f"Separated {self.device_type} - {self.mac_address}")
408+
print(f" Public key: {self.adv_key_b64}")
409+
print(f" Lookup key: {self.hashed_adv_key_b64}")
410+
print(f" Status byte: 0x{self.status:x}")
411+
print(f" Battery lvl: {self.battery_level}")
412+
print(f" Hint byte: 0x{self.hint:x}")
413+
print(f" RSSI: {self.rssi}")
414+
print(" Extra data:")
415+
for k, v in sorted(self.additional_data.items()):
416+
print(f" {k:20}: {v}")
417+
print("\n")
380418

381419
@override
382420
def __repr__(self) -> str:
@@ -416,35 +454,33 @@ def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
416454

417455
def print_scanning_results(self, seen_devices: dict[str, list[OfflineFindingDevice]]) -> None:
418456
"""Print summary of each device seen."""
419-
logger.info("================ RESULTS =========================")
457+
# ruff: noqa: T201
458+
print("================ RESULTS =========================")
420459
for mac, devices in seen_devices.items():
421460
avg_rssi = sum(d.rssi for d in devices if d.rssi is not None) / len(
422461
[d for d in devices if d.rssi is not None]
423462
)
424-
logger.info("Device %s seen %d times, average RSSI: %.1f", mac, len(devices), avg_rssi)
463+
print(f"Device {mac} seen {len(devices)} times, average RSSI: {avg_rssi:.1f}")
425464
of_type = (
426465
"nearby" if isinstance(devices[0], NearbyOfflineFindingDevice) else "separated"
427466
)
428-
logger.info(
429-
" %s with %s battery (%s state)",
430-
devices[0].device_type,
431-
devices[0].battery_level,
432-
of_type,
467+
print(
468+
f" {devices[0].device_type} with {devices[0].battery_level} battery"
469+
f" ({of_type} state)"
433470
)
434471

435472
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("===============================================")
473+
print(f" Public key: {devices[0].adv_key_b64}")
474+
print(f" Lookup key: {devices[0].hashed_adv_key_b64}")
475+
print("===============================================")
440476

441477
device_type_counts: dict[str, int] = {}
442478
for devs in seen_devices.values():
443479
dev_type = devs[0].device_type
444480
device_type_counts[dev_type] = device_type_counts.get(dev_type, 0) + 1
445481

446482
for dev_type, count in device_type_counts.items():
447-
logger.info("Total %s: %d", dev_type, count)
483+
print(f"Total {dev_type}: {count}")
448484

449485
@classmethod
450486
async def create(cls) -> OfflineFindingScanner:
@@ -485,10 +521,8 @@ async def _wait_for_device(self, timeout: float) -> OfflineFindingDevice | None:
485521

486522
detected_at = datetime.now().astimezone()
487523

488-
# Extract RSSI if it exists
489-
rssi = None
490-
if data.rssi is not None:
491-
rssi = data.rssi
524+
# Extract RSSI (which may be None)
525+
rssi = data.rssi
492526

493527
try:
494528
additional_data = device.details.get("props", {})
@@ -509,6 +543,7 @@ async def scan_for(
509543
timeout: float = 10,
510544
*,
511545
extend_timeout: bool = False,
546+
print_summary: bool = False,
512547
) -> AsyncGenerator[OfflineFindingDevice, None]:
513548
"""
514549
Scan for :meth:`OfflineFindingDevice`s for up to :meth:`timeout` seconds.
@@ -546,7 +581,8 @@ async def scan_for(
546581

547582
except asyncio.TimeoutError: # timeout reached
548583
self._device_fut = self._loop.create_future()
549-
self.print_scanning_results(devices_seen)
584+
if print_summary:
585+
self.print_scanning_results(devices_seen)
550586
return
551587
finally:
552588
await self._stop_scan()

0 commit comments

Comments
 (0)