2727logger = logging .getLogger (__name__ )
2828
2929
30+ def _extract_serial_from_stable_id (stable : list [str ] | None ) -> str | None :
31+ """
32+ Extract the hardware serial from a plist `stableIdentifier`.
33+
34+ Observed formats of the first entry:
35+ AirTag: "2006~#<hwid>~#<serial>"
36+ 3rd-party: "a:/<uuid>~#<serial>"
37+ AirPods: "a:/<uuid>~#¶<model>§<hwid>§<hex-ascii-serial>§<position>"
38+ position 0/1 = left/right bud, 2 = case.
39+ """
40+ if not stable :
41+ return None
42+ tail = stable [0 ].split ("~#" )[- 1 ]
43+ if tail .startswith ("\u00b6 " ): # ¶ — AirPods-style structured tail
44+ sections = tail .split ("\u00a7 " ) # § — section separator
45+ if len (sections ) >= 3 :
46+ try :
47+ return bytes .fromhex (sections [2 ]).decode ("ascii" )
48+ except (ValueError , UnicodeDecodeError ):
49+ return None
50+ return None
51+ return tail if tail != stable [0 ] else None
52+
53+
3054class FindMyAccessoryMapping (TypedDict ):
3155 """JSON mapping representing state of a FindMyAccessory instance."""
3256
@@ -38,6 +62,7 @@ class FindMyAccessoryMapping(TypedDict):
3862 name : str | None
3963 model : str | None
4064 identifier : str | None
65+ serial_number : str | None
4166 alignment_date : str | None
4267 alignment_index : int | None
4368
@@ -109,6 +134,7 @@ def __init__( # noqa: PLR0913
109134 name : str | None = None ,
110135 model : str | None = None ,
111136 identifier : str | None = None ,
137+ serial_number : str | None = None ,
112138 alignment_date : datetime | None = None ,
113139 alignment_index : int | None = None ,
114140 ) -> None :
@@ -132,6 +158,7 @@ def __init__( # noqa: PLR0913
132158 self ._name = name
133159 self ._model = model
134160 self ._identifier = identifier
161+ self ._serial_number = serial_number
135162 self ._alignment_date = alignment_date if alignment_date is not None else paired_at
136163 self ._alignment_index = alignment_index if alignment_index is not None else 0
137164 if self ._alignment_date .tzinfo is None :
@@ -180,6 +207,11 @@ def identifier(self) -> str | None:
180207 """Internal identifier of this accessory."""
181208 return self ._identifier
182209
210+ @property
211+ def serial_number (self ) -> str | None :
212+ """Hardware serial number of this accessory, if known."""
213+ return self ._serial_number
214+
183215 @property
184216 @override
185217 def interval (self ) -> timedelta :
@@ -300,6 +332,10 @@ def from_plist(
300332 model = device_data ["model" ]
301333 identifier = device_data ["identifier" ]
302334
335+ serial_number = _extract_serial_from_stable_id (
336+ device_data .get ("stableIdentifier" ),
337+ )
338+
303339 alignment_date = None
304340 index = None
305341 if key_alignment_plist :
@@ -320,6 +356,7 @@ def from_plist(
320356 name = name ,
321357 model = model ,
322358 identifier = identifier ,
359+ serial_number = serial_number ,
323360 alignment_date = alignment_date ,
324361 alignment_index = index ,
325362 )
@@ -339,6 +376,7 @@ def to_json(self, path: str | Path | io.TextIOBase | None = None, /) -> FindMyAc
339376 "name" : self .name ,
340377 "model" : self .model ,
341378 "identifier" : self .identifier ,
379+ "serial_number" : self .serial_number ,
342380 "alignment_date" : alignment_date ,
343381 "alignment_index" : self ._alignment_index ,
344382 }
@@ -368,6 +406,7 @@ def from_json(
368406 name = val ["name" ],
369407 model = val ["model" ],
370408 identifier = val ["identifier" ],
409+ serial_number = val .get ("serial_number" ),
371410 alignment_date = alignment_date ,
372411 alignment_index = val ["alignment_index" ],
373412 )
0 commit comments