33from __future__ import annotations
44
55import asyncio
6+ import json
67import logging
78import time
89from abc import ABC , abstractmethod
1718
1819if 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
2426logger = logging .getLogger (__name__ )
2527
2628APPLE_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
3638class 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
267286class 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