Skip to content

Commit ae5a031

Browse files
authored
Merge pull request #239 from Yannik/extract-serial-numbers
Extract serial numbers
2 parents 7e3e7f6 + 1a46570 commit ae5a031

1 file changed

Lines changed: 39 additions & 0 deletions

File tree

findmy/accessory.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,30 @@
2727
logger = 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+
3054
class 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

Comments
 (0)