apple-ble 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+
9
+ # Test / coverage
10
+ .pytest_cache/
11
+ .coverage
12
+ htmlcov/
13
+
14
+ # Environments
15
+ .venv/
16
+ venv/
17
+ .env
18
+
19
+ # uv
20
+ uv.lock
21
+
22
+ # OS
23
+ .DS_Store
@@ -0,0 +1,50 @@
1
+ Metadata-Version: 2.4
2
+ Name: apple-ble
3
+ Version: 0.1.0
4
+ Summary: Pure-Python parser for Apple Continuity BLE proximity-pairing adverts (AirPods battery + Apple device presence)
5
+ Project-URL: Homepage, https://github.com/hudsonbrendon/apple-ble
6
+ Author-email: Hudson Brendon <contato.hudsonbrendon@gmail.com>
7
+ License: MIT
8
+ Keywords: airpods,apple,ble,bluetooth,continuity,home-assistant
9
+ Requires-Python: >=3.12
10
+ Provides-Extra: cli
11
+ Requires-Dist: bleak>=0.22; extra == 'cli'
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest-cov>=5; extra == 'dev'
14
+ Requires-Dist: pytest>=8; extra == 'dev'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # apple-ble
18
+
19
+ Pure-Python parser for Apple Continuity BLE *proximity-pairing* advertisements.
20
+ Reads AirPods battery (case / left / right) and charging state from the
21
+ unencrypted manufacturer-76 advert. No connection required, no HA dependency.
22
+
23
+ ## Install
24
+ ```bash
25
+ pip install apple-ble # core (parsing only)
26
+ pip install "apple-ble[cli]" # + bleak scanner CLI
27
+ ```
28
+
29
+ ## Use as a library
30
+ ```python
31
+ from apple_ble import parse_proximity_pairing, APPLE_MANUFACTURER_ID
32
+
33
+ # `payload` is manufacturer_data[76] from any BLE stack (bleak, HA, etc.)
34
+ data = parse_proximity_pairing(payload)
35
+ if data:
36
+ print(data.model, data.left_battery, data.right_battery, data.case_battery)
37
+ ```
38
+
39
+ ## Scan from the terminal
40
+ ```bash
41
+ apple-ble # opens a 15s BLE scan, prints AirPods it sees
42
+ ```
43
+
44
+ ## Limitations
45
+ - Only AirPods expose battery over BLE. Apple Watch / iPhone do **not**.
46
+ - Apple rotates the BLE MAC (~15 min); there is no stable per-device id in the advert.
47
+ - Battery is reported in coarse 10% steps.
48
+
49
+ Reverse-engineering credit: furiousMAC/continuity, kavishdevar/librepods,
50
+ delphiki/AirStatus, d4rken-org/capod.
@@ -0,0 +1,34 @@
1
+ # apple-ble
2
+
3
+ Pure-Python parser for Apple Continuity BLE *proximity-pairing* advertisements.
4
+ Reads AirPods battery (case / left / right) and charging state from the
5
+ unencrypted manufacturer-76 advert. No connection required, no HA dependency.
6
+
7
+ ## Install
8
+ ```bash
9
+ pip install apple-ble # core (parsing only)
10
+ pip install "apple-ble[cli]" # + bleak scanner CLI
11
+ ```
12
+
13
+ ## Use as a library
14
+ ```python
15
+ from apple_ble import parse_proximity_pairing, APPLE_MANUFACTURER_ID
16
+
17
+ # `payload` is manufacturer_data[76] from any BLE stack (bleak, HA, etc.)
18
+ data = parse_proximity_pairing(payload)
19
+ if data:
20
+ print(data.model, data.left_battery, data.right_battery, data.case_battery)
21
+ ```
22
+
23
+ ## Scan from the terminal
24
+ ```bash
25
+ apple-ble # opens a 15s BLE scan, prints AirPods it sees
26
+ ```
27
+
28
+ ## Limitations
29
+ - Only AirPods expose battery over BLE. Apple Watch / iPhone do **not**.
30
+ - Apple rotates the BLE MAC (~15 min); there is no stable per-device id in the advert.
31
+ - Battery is reported in coarse 10% steps.
32
+
33
+ Reverse-engineering credit: furiousMAC/continuity, kavishdevar/librepods,
34
+ delphiki/AirStatus, d4rken-org/capod.
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "apple-ble"
7
+ version = "0.1.0"
8
+ description = "Pure-Python parser for Apple Continuity BLE proximity-pairing adverts (AirPods battery + Apple device presence)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Hudson Brendon", email = "contato.hudsonbrendon@gmail.com" }]
13
+ keywords = ["airpods", "ble", "bluetooth", "apple", "continuity", "home-assistant"]
14
+ dependencies = []
15
+
16
+ [project.optional-dependencies]
17
+ cli = ["bleak>=0.22"]
18
+ dev = ["pytest>=8", "pytest-cov>=5"]
19
+
20
+ [project.scripts]
21
+ apple-ble = "apple_ble.scanner:main"
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/hudsonbrendon/apple-ble"
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["src/apple_ble"]
28
+
29
+ [tool.pytest.ini_options]
30
+ pythonpath = ["src"]
31
+ testpaths = ["tests"]
@@ -0,0 +1,16 @@
1
+ """Pure-Python parser for Apple Continuity BLE proximity-pairing adverts."""
2
+
3
+ from .const import APPLE_MANUFACTURER_ID, MODEL_BY_CHAR
4
+ from .models import AirPodsData, AppleAdvert
5
+ from .parser import decode_battery_nibble, parse_proximity_pairing
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ __all__ = [
10
+ "APPLE_MANUFACTURER_ID",
11
+ "MODEL_BY_CHAR",
12
+ "AirPodsData",
13
+ "AppleAdvert",
14
+ "decode_battery_nibble",
15
+ "parse_proximity_pairing",
16
+ ]
@@ -0,0 +1,33 @@
1
+ """Constants for the Apple Continuity proximity-pairing protocol."""
2
+
3
+ # Apple's Bluetooth company identifier (0x004C).
4
+ APPLE_MANUFACTURER_ID: int = 76
5
+
6
+ # Continuity message type for AirPods battery/proximity pairing.
7
+ MSG_TYPE_PROXIMITY_PAIRING: int = 0x07
8
+
9
+ # A proximity-pairing payload (after the company id) is 27 bytes = 54 hex chars.
10
+ PROXIMITY_PAIRING_HEX_LEN: int = 54
11
+
12
+ # Nibble indices into the 54-char hex string of manufacturer_data[76].
13
+ # These mirror the well-tested AirStatus layout.
14
+ IDX_MODEL: int = 7
15
+ IDX_FLIP: int = 10
16
+ IDX_LEFT_NOFLIP: int = 13
17
+ IDX_RIGHT_NOFLIP: int = 12
18
+ IDX_CHARGE: int = 14
19
+ IDX_CASE: int = 15
20
+
21
+ # Battery nibble value 0x0F means "not present / unknown".
22
+ NIBBLE_UNKNOWN: int = 0x0F
23
+
24
+ # Model is identified by the single hex char at IDX_MODEL — the low nibble of
25
+ # the device-model byte (e.g. 0x0E -> "e" = AirPods Pro, 0x14 -> "4" = Pro 2).
26
+ MODEL_BY_CHAR: dict[str, str] = {
27
+ "2": "AirPods (1st gen)",
28
+ "f": "AirPods (2nd gen)",
29
+ "3": "AirPods (3rd gen)",
30
+ "e": "AirPods Pro",
31
+ "4": "AirPods Pro 2",
32
+ "a": "AirPods Max",
33
+ }
@@ -0,0 +1,31 @@
1
+ """Immutable data models returned by the parser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class AirPodsData:
10
+ """Parsed AirPods proximity-pairing state.
11
+
12
+ Battery values are integer percentages in 10% steps, or None when the
13
+ pod/case is not present or the value is unknown (nibble 0x0F).
14
+ """
15
+
16
+ model: str
17
+ left_battery: int | None
18
+ right_battery: int | None
19
+ case_battery: int | None
20
+ left_charging: bool
21
+ right_charging: bool
22
+ case_charging: bool
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class AppleAdvert:
27
+ """A minimal record of any Apple manufacturer-76 advertisement seen."""
28
+
29
+ address: str
30
+ rssi: int
31
+ is_proximity_pairing: bool
@@ -0,0 +1,64 @@
1
+ """Parse Apple Continuity proximity-pairing advertisements into AirPodsData."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from . import const
6
+ from .models import AirPodsData
7
+
8
+
9
+ def decode_battery_nibble(nibble: int) -> int | None:
10
+ """Convert a 0x0-0xF battery nibble to a percentage in 10% steps.
11
+
12
+ Apple reports battery in deciles: 0..10 -> 0..100%. Values 11-15 mean
13
+ "not present / unknown" and yield None.
14
+ """
15
+ if nibble > 10:
16
+ return None
17
+ return nibble * 10
18
+
19
+
20
+ def _is_flipped(hexstr: str) -> bool:
21
+ """AirPods report L/R swapped depending on orientation bit."""
22
+ return (int(hexstr[const.IDX_FLIP], 16) & 0x02) == 0
23
+
24
+
25
+ def parse_proximity_pairing(data: bytes) -> AirPodsData | None:
26
+ """Parse manufacturer_data[76] bytes into AirPodsData, or None if not AirPods.
27
+
28
+ `data` must be the bytes that follow Apple's company id (i.e. starting with
29
+ the message type byte). Returns None for any advert that is not a complete
30
+ proximity-pairing message.
31
+ """
32
+ if not data or data[0] != const.MSG_TYPE_PROXIMITY_PAIRING:
33
+ return None
34
+
35
+ hexstr = data.hex()
36
+ if len(hexstr) < const.PROXIMITY_PAIRING_HEX_LEN:
37
+ return None
38
+
39
+ model = const.MODEL_BY_CHAR.get(hexstr[const.IDX_MODEL], "AirPods")
40
+
41
+ flipped = _is_flipped(hexstr)
42
+ left_idx = const.IDX_RIGHT_NOFLIP if flipped else const.IDX_LEFT_NOFLIP
43
+ right_idx = const.IDX_LEFT_NOFLIP if flipped else const.IDX_RIGHT_NOFLIP
44
+
45
+ left_battery = decode_battery_nibble(int(hexstr[left_idx], 16))
46
+ right_battery = decode_battery_nibble(int(hexstr[right_idx], 16))
47
+ case_battery = decode_battery_nibble(int(hexstr[const.IDX_CASE], 16))
48
+
49
+ charge = int(hexstr[const.IDX_CHARGE], 16)
50
+ left_bit = 0b0010 if flipped else 0b0001
51
+ right_bit = 0b0001 if flipped else 0b0010
52
+ left_charging = bool(charge & left_bit)
53
+ right_charging = bool(charge & right_bit)
54
+ case_charging = bool(charge & 0b0100)
55
+
56
+ return AirPodsData(
57
+ model=model,
58
+ left_battery=left_battery,
59
+ right_battery=right_battery,
60
+ case_battery=case_battery,
61
+ left_charging=left_charging,
62
+ right_charging=right_charging,
63
+ case_charging=case_charging,
64
+ )
@@ -0,0 +1,57 @@
1
+ """Optional standalone BLE scanner CLI (requires the `cli` extra: bleak)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ from . import const
8
+ from .models import AirPodsData
9
+ from .parser import parse_proximity_pairing
10
+
11
+
12
+ def _fmt(v: int | None) -> str:
13
+ """Format a battery percentage for display; absent pods show '--'."""
14
+ return "--" if v is None else f"{v}%"
15
+
16
+
17
+ def advert_to_airpods(advertisement_data) -> AirPodsData | None:
18
+ """Extract AirPodsData from a bleak AdvertisementData-like object."""
19
+ mfr = getattr(advertisement_data, "manufacturer_data", {}) or {}
20
+ payload = mfr.get(const.APPLE_MANUFACTURER_ID)
21
+ if payload is None:
22
+ return None
23
+ return parse_proximity_pairing(bytes(payload))
24
+
25
+
26
+ async def _scan(seconds: float) -> None:
27
+ from bleak import BleakScanner # imported lazily so core lib has no dep
28
+
29
+ seen: dict[str, AirPodsData] = {}
30
+
31
+ def _cb(device, advertisement_data) -> None:
32
+ data = advert_to_airpods(advertisement_data)
33
+ if data is not None:
34
+ seen[device.address] = data
35
+ print(
36
+ f"[{advertisement_data.rssi} dBm] {data.model}: "
37
+ f"L={_fmt(data.left_battery)} R={_fmt(data.right_battery)} "
38
+ f"case={_fmt(data.case_battery)} "
39
+ f"(charging L/R/case={data.left_charging}/"
40
+ f"{data.right_charging}/{data.case_charging})"
41
+ )
42
+
43
+ scanner = BleakScanner(detection_callback=_cb)
44
+ await scanner.start()
45
+ await asyncio.sleep(seconds)
46
+ await scanner.stop()
47
+ if not seen:
48
+ print("No AirPods adverts seen. Open the lid near the scanner and retry.")
49
+
50
+
51
+ def main() -> None:
52
+ """Console entry point: `apple-ble`."""
53
+ asyncio.run(_scan(15.0))
54
+
55
+
56
+ if __name__ == "__main__":
57
+ main()
File without changes
@@ -0,0 +1,25 @@
1
+ from apple_ble import const
2
+
3
+
4
+ def test_apple_manufacturer_id_is_76():
5
+ assert const.APPLE_MANUFACTURER_ID == 76
6
+
7
+
8
+ def test_proximity_pairing_type_is_0x07():
9
+ assert const.MSG_TYPE_PROXIMITY_PAIRING == 0x07
10
+
11
+
12
+ def test_model_map_covers_known_airpods():
13
+ assert const.MODEL_BY_CHAR["e"] == "AirPods Pro"
14
+ assert const.MODEL_BY_CHAR["4"] == "AirPods Pro 2"
15
+ assert const.MODEL_BY_CHAR["a"] == "AirPods Max"
16
+ assert const.MODEL_BY_CHAR["2"] == "AirPods (1st gen)"
17
+
18
+
19
+ def test_nibble_indices():
20
+ assert const.IDX_MODEL == 7
21
+ assert const.IDX_FLIP == 10
22
+ assert const.IDX_LEFT_NOFLIP == 13
23
+ assert const.IDX_RIGHT_NOFLIP == 12
24
+ assert const.IDX_CHARGE == 14
25
+ assert const.IDX_CASE == 15
@@ -0,0 +1,35 @@
1
+ from apple_ble.models import AirPodsData
2
+
3
+
4
+ def test_airpods_data_holds_fields():
5
+ data = AirPodsData(
6
+ model="AirPods Pro",
7
+ left_battery=90,
8
+ right_battery=100,
9
+ case_battery=None,
10
+ left_charging=False,
11
+ right_charging=True,
12
+ case_charging=False,
13
+ )
14
+ assert data.model == "AirPods Pro"
15
+ assert data.left_battery == 90
16
+ assert data.case_battery is None
17
+ assert data.right_charging is True
18
+
19
+
20
+ def test_airpods_data_is_frozen():
21
+ data = AirPodsData(
22
+ model="AirPods Pro",
23
+ left_battery=None,
24
+ right_battery=None,
25
+ case_battery=None,
26
+ left_charging=False,
27
+ right_charging=False,
28
+ case_charging=False,
29
+ )
30
+ try:
31
+ data.model = "x" # type: ignore[misc]
32
+ except Exception as exc: # frozen dataclass raises FrozenInstanceError
33
+ assert "cannot assign" in str(exc).lower() or "frozen" in str(exc).lower()
34
+ else:
35
+ raise AssertionError("AirPodsData should be immutable")
@@ -0,0 +1,173 @@
1
+ from apple_ble.parser import decode_battery_nibble
2
+ from apple_ble.parser import parse_proximity_pairing
3
+
4
+
5
+ def test_full_battery():
6
+ assert decode_battery_nibble(10) == 100
7
+
8
+
9
+ def test_mid_battery():
10
+ assert decode_battery_nibble(9) == 90
11
+ assert decode_battery_nibble(5) == 50
12
+
13
+
14
+ def test_empty_battery():
15
+ assert decode_battery_nibble(0) == 0
16
+
17
+
18
+ def test_unknown_nibble_is_none():
19
+ assert decode_battery_nibble(15) is None
20
+ assert decode_battery_nibble(11) is None
21
+
22
+
23
+ # A 27-byte (54 hex char) proximity-pairing advert.
24
+ # Layout (nibble indices): model@7='e' (AirPods Pro), flip@10='7',
25
+ # right@12='a'(=10 ->100%), left@13='9'(->90%), charge@14='3'
26
+ # (bit0 set, bit1 set, bit2 clear), case@15='6'(->60%).
27
+
28
+
29
+ def _sample_bytes() -> bytes:
30
+ # Nibble layout: type@0-1='07', model@7='e'(AirPods Pro), flip@10='7'(not flipped),
31
+ # right@12='a'(->100%), left@13='9'(->90%), charge@14='3'(L+R charging, case not),
32
+ # case@15='6'(->60%).
33
+ hexstr = "0719070e2070a93601000045121212"
34
+ hexstr = hexstr + "0" * (54 - len(hexstr)) # right-pad nibbles to 54 chars
35
+ return bytes.fromhex(hexstr)
36
+
37
+
38
+ def test_parses_model_and_batteries():
39
+ data = parse_proximity_pairing(_sample_bytes())
40
+ assert data is not None
41
+ assert data.model == "AirPods Pro"
42
+ # flip nibble @10 = '7'; int('7',16)&0x02 == 2 (nonzero) -> NOT flipped
43
+ # not flipped: left=idx13, right=idx12
44
+ assert data.right_battery == 100 # idx12 = 'a' = 10 -> 100
45
+ assert data.left_battery == 90 # idx13 = '9' -> 90
46
+
47
+
48
+ def test_parses_charging_flags():
49
+ data = parse_proximity_pairing(_sample_bytes())
50
+ assert data is not None
51
+ # charge nibble @14 = '3' = 0b0011, not flipped:
52
+ assert data.left_charging is True # bit0
53
+ assert data.right_charging is True # bit1
54
+ assert data.case_charging is False # bit2
55
+
56
+
57
+ def test_case_battery():
58
+ data = parse_proximity_pairing(_sample_bytes())
59
+ assert data is not None
60
+ assert data.case_battery == 60 # idx15 = '6' -> 60
61
+
62
+
63
+ def test_rejects_non_proximity_pairing():
64
+ # Type byte 0x10 (not 0x07) -> not a proximity pairing message.
65
+ assert parse_proximity_pairing(bytes.fromhex("10" + "0" * 52)) is None
66
+
67
+
68
+ def test_rejects_wrong_length():
69
+ assert parse_proximity_pairing(bytes.fromhex("0719")) is None
70
+
71
+
72
+ def test_rejects_empty():
73
+ assert parse_proximity_pairing(b"") is None
74
+
75
+
76
+ def test_public_api_exports():
77
+ import apple_ble
78
+
79
+ assert hasattr(apple_ble, "parse_proximity_pairing")
80
+ assert hasattr(apple_ble, "AirPodsData")
81
+ assert apple_ble.APPLE_MANUFACTURER_ID == 76
82
+
83
+
84
+ def _make_hexstr(charge_nibble: str, left_nibble: str = "9", right_nibble: str = "a",
85
+ flip_nibble: str = "7") -> bytes:
86
+ """Build a NOT-flipped (flip_nibble='7') advert with custom charge/battery nibbles.
87
+
88
+ Hex layout (indices):
89
+ 0-1: type '07'
90
+ 2-5: '1907' (payload length + subtype)
91
+ 6-7: '0e' model byte (idx6='0', idx7='e' -> AirPods Pro)
92
+ 8-9: '20'
93
+ 10: flip_nibble (default '7' = 0b0111, bit1 set -> NOT flipped)
94
+ 11: '0'
95
+ 12: right_nibble (IDX_RIGHT_NOFLIP)
96
+ 13: left_nibble (IDX_LEFT_NOFLIP)
97
+ 14: charge_nibble
98
+ 15: '6' case battery -> 60%
99
+ 16+: zeros padded to 54 chars
100
+ """
101
+ s = "071907" + "0e" + "20" + flip_nibble + "0" + right_nibble + left_nibble + charge_nibble + "6"
102
+ s = s + "0" * (54 - len(s))
103
+ return bytes.fromhex(s)
104
+
105
+
106
+ # --- Charging-bit attribution tests (NOT flipped, flip nibble = '7') ---
107
+
108
+ def test_charging_only_left():
109
+ # charge nibble '1' = 0b0001; not flipped: left_bit=0b0001, right_bit=0b0010
110
+ # -> left_charging True, right_charging False, case_charging False
111
+ data = parse_proximity_pairing(_make_hexstr(charge_nibble="1", left_nibble="5", right_nibble="8"))
112
+ assert data is not None
113
+ assert data.left_charging is True
114
+ assert data.right_charging is False
115
+ assert data.case_charging is False
116
+ # Confirm batteries are correctly read (distinct nibbles, not-flipped)
117
+ assert data.left_battery == 50 # idx13='5'
118
+ assert data.right_battery == 80 # idx12='8'
119
+
120
+
121
+ def test_charging_only_right():
122
+ # charge nibble '2' = 0b0010; not flipped: left_bit=0b0001, right_bit=0b0010
123
+ # -> left_charging False, right_charging True
124
+ data = parse_proximity_pairing(_make_hexstr(charge_nibble="2", left_nibble="3", right_nibble="7"))
125
+ assert data is not None
126
+ assert data.left_charging is False
127
+ assert data.right_charging is True
128
+
129
+
130
+ def test_charging_only_case():
131
+ # charge nibble '4' = 0b0100; case_bit=0b0100
132
+ # -> case_charging True, left/right False
133
+ data = parse_proximity_pairing(_make_hexstr(charge_nibble="4", left_nibble="2", right_nibble="6"))
134
+ assert data is not None
135
+ assert data.left_charging is False
136
+ assert data.right_charging is False
137
+ assert data.case_charging is True
138
+
139
+
140
+ # --- Flipped-orientation test ---
141
+
142
+ def test_flipped_swaps_indices_and_charging_bits():
143
+ # flip_nibble '4' = 0b0100; bit1 clear (0x02 & 0x04 == 0) -> FLIPPED
144
+ # In flipped mode: left reads IDX_RIGHT_NOFLIP (idx12), right reads IDX_LEFT_NOFLIP (idx13)
145
+ # idx12='2' -> 20%, idx13='8' -> 80%
146
+ # charge nibble '1' = 0b0001; flipped: left_bit=0b0010, right_bit=0b0001
147
+ # -> bit0 set hits right_bit -> right_charging True, left_charging False
148
+ data = parse_proximity_pairing(_make_hexstr(
149
+ charge_nibble="1", left_nibble="8", right_nibble="2", flip_nibble="4"
150
+ ))
151
+ assert data is not None
152
+ # Index swap: left reads idx12='2'->20, right reads idx13='8'->80
153
+ assert data.left_battery == 20
154
+ assert data.right_battery == 80
155
+ # Bit swap: charge bit 0b0001 -> right_charging (not left) when flipped
156
+ assert data.left_charging is False
157
+ assert data.right_charging is True
158
+
159
+
160
+ def test_real_hardware_airpods_pro_2():
161
+ # Captured live from real AirPods Pro 2 (device-model bytes 0x14 0x20) over
162
+ # the Mac's built-in Bluetooth. Pods were out of the case and flipped.
163
+ # This is a real-world regression fixture, not a synthetic one.
164
+ raw = bytes.fromhex("07190114200b538f10000848273aff815e50000000261e7bfab684")
165
+ data = parse_proximity_pairing(raw)
166
+ assert data is not None
167
+ assert data.model == "AirPods Pro 2" # nibble@7='4'
168
+ assert data.left_battery == 50 # flip@10='0' -> flipped; left=idx12='5'
169
+ assert data.right_battery == 30 # right=idx13='3'
170
+ assert data.case_battery is None # case@15='f' -> not present
171
+ assert data.left_charging is False
172
+ assert data.right_charging is False
173
+ assert data.case_charging is False
@@ -0,0 +1,20 @@
1
+ from apple_ble.scanner import advert_to_airpods
2
+
3
+
4
+ class _FakeAdvData:
5
+ def __init__(self, manufacturer_data):
6
+ self.manufacturer_data = manufacturer_data
7
+
8
+
9
+ def test_advert_to_airpods_parses_apple_data():
10
+ hexstr = "0719070e2070a93601000045121212"
11
+ hexstr = hexstr + "0" * (54 - len(hexstr))
12
+ adv = _FakeAdvData({76: bytes.fromhex(hexstr)})
13
+ data = advert_to_airpods(adv)
14
+ assert data is not None
15
+ assert data.model == "AirPods Pro"
16
+
17
+
18
+ def test_advert_to_airpods_ignores_non_apple():
19
+ adv = _FakeAdvData({6: b"\x01\x02"}) # Microsoft, not Apple
20
+ assert advert_to_airpods(adv) is None