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.
- apple_ble-0.1.0/.gitignore +23 -0
- apple_ble-0.1.0/PKG-INFO +50 -0
- apple_ble-0.1.0/README.md +34 -0
- apple_ble-0.1.0/pyproject.toml +31 -0
- apple_ble-0.1.0/src/apple_ble/__init__.py +16 -0
- apple_ble-0.1.0/src/apple_ble/const.py +33 -0
- apple_ble-0.1.0/src/apple_ble/models.py +31 -0
- apple_ble-0.1.0/src/apple_ble/parser.py +64 -0
- apple_ble-0.1.0/src/apple_ble/scanner.py +57 -0
- apple_ble-0.1.0/tests/__init__.py +0 -0
- apple_ble-0.1.0/tests/test_const.py +25 -0
- apple_ble-0.1.0/tests/test_models.py +35 -0
- apple_ble-0.1.0/tests/test_parser.py +173 -0
- apple_ble-0.1.0/tests/test_scanner.py +20 -0
apple_ble-0.1.0/PKG-INFO
ADDED
|
@@ -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
|