2323
2424logger = 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
2736class 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:
131168class 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
210267class 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