python-igps-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.
- python_igps_ble-0.1.0/PKG-INFO +78 -0
- python_igps_ble-0.1.0/README.md +62 -0
- python_igps_ble-0.1.0/pyproject.toml +37 -0
- python_igps_ble-0.1.0/setup.cfg +4 -0
- python_igps_ble-0.1.0/src/igps_ble/__init__.py +7 -0
- python_igps_ble-0.1.0/src/igps_ble/__main__.py +46 -0
- python_igps_ble-0.1.0/src/igps_ble/client.py +69 -0
- python_igps_ble-0.1.0/src/igps_ble/const.py +26 -0
- python_igps_ble-0.1.0/src/igps_ble/models.py +23 -0
- python_igps_ble-0.1.0/src/igps_ble/parser.py +21 -0
- python_igps_ble-0.1.0/src/igps_ble/scanner.py +32 -0
- python_igps_ble-0.1.0/src/python_igps_ble.egg-info/PKG-INFO +78 -0
- python_igps_ble-0.1.0/src/python_igps_ble.egg-info/SOURCES.txt +18 -0
- python_igps_ble-0.1.0/src/python_igps_ble.egg-info/dependency_links.txt +1 -0
- python_igps_ble-0.1.0/src/python_igps_ble.egg-info/entry_points.txt +2 -0
- python_igps_ble-0.1.0/src/python_igps_ble.egg-info/requires.txt +6 -0
- python_igps_ble-0.1.0/src/python_igps_ble.egg-info/top_level.txt +1 -0
- python_igps_ble-0.1.0/tests/test_client.py +100 -0
- python_igps_ble-0.1.0/tests/test_parser.py +31 -0
- python_igps_ble-0.1.0/tests/test_scanner.py +46 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-igps-ble
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pure-Python BLE library for the iGPSPORT iGS10S cycling computer (presence, battery, device info)
|
|
5
|
+
Author-email: Hudson Brendon <contato.hudsonbrendon@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/hudsonbrendon/python-igps-ble
|
|
8
|
+
Project-URL: Issues, https://github.com/hudsonbrendon/python-igps-ble/issues
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: bleak>=0.22
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
14
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
15
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
16
|
+
|
|
17
|
+
# python-igps-ble
|
|
18
|
+
|
|
19
|
+
Pure-Python BLE library for the **iGPSPORT iGS10S** cycling computer.
|
|
20
|
+
|
|
21
|
+
Reads, over standard Bluetooth Low Energy, what the device exposes to
|
|
22
|
+
third parties: **presence**, **battery level**, and **device info**
|
|
23
|
+
(model / firmware / manufacturer). The proprietary `.fit` sync used by the
|
|
24
|
+
official iGPSPORT app is **out of scope** (see `ha-igps-ble/docs/PROTOCOL.md`).
|
|
25
|
+
|
|
26
|
+
> Built to back the [`ha-igps-ble`](https://github.com/hudsonbrendon/ha-igps-ble)
|
|
27
|
+
> Home Assistant integration, but usable standalone.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install python-igps-ble
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## CLI
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
igps-ble scan # procura aparelhos iGPSPORT por perto
|
|
39
|
+
igps-ble info AA:BB:CC:DD:EE:FF # bateria + device-info
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Uso programático
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import asyncio
|
|
46
|
+
from igps_ble import IGPSDeviceState
|
|
47
|
+
from igps_ble.scanner import discover_devices
|
|
48
|
+
from igps_ble.client import IGPSClient
|
|
49
|
+
|
|
50
|
+
async def main():
|
|
51
|
+
for dev in await discover_devices(timeout=10):
|
|
52
|
+
state: IGPSDeviceState = await IGPSClient(dev.address, dev.name).async_read_state()
|
|
53
|
+
print(state)
|
|
54
|
+
|
|
55
|
+
asyncio.run(main())
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Escopo
|
|
59
|
+
|
|
60
|
+
| Dado | Suportado | Fonte |
|
|
61
|
+
|------|-----------|-------|
|
|
62
|
+
| Presença | ✅ | advertisement BLE |
|
|
63
|
+
| Bateria % | ✅* | Battery Service `0x180F` |
|
|
64
|
+
| Modelo / firmware | ✅* | Device Information `0x180A` |
|
|
65
|
+
| Velocidade / distância / pedalada | ❌ | sync proprietário (não exposto por BLE) |
|
|
66
|
+
|
|
67
|
+
\* depende de o iGS10S expor o serviço SIG correspondente — confirmado por recon.
|
|
68
|
+
|
|
69
|
+
## Desenvolvimento
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
python -m venv .venv && .venv/bin/pip install -e ".[dev]"
|
|
73
|
+
.venv/bin/pytest
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Licença
|
|
77
|
+
|
|
78
|
+
MIT
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# python-igps-ble
|
|
2
|
+
|
|
3
|
+
Pure-Python BLE library for the **iGPSPORT iGS10S** cycling computer.
|
|
4
|
+
|
|
5
|
+
Reads, over standard Bluetooth Low Energy, what the device exposes to
|
|
6
|
+
third parties: **presence**, **battery level**, and **device info**
|
|
7
|
+
(model / firmware / manufacturer). The proprietary `.fit` sync used by the
|
|
8
|
+
official iGPSPORT app is **out of scope** (see `ha-igps-ble/docs/PROTOCOL.md`).
|
|
9
|
+
|
|
10
|
+
> Built to back the [`ha-igps-ble`](https://github.com/hudsonbrendon/ha-igps-ble)
|
|
11
|
+
> Home Assistant integration, but usable standalone.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install python-igps-ble
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## CLI
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
igps-ble scan # procura aparelhos iGPSPORT por perto
|
|
23
|
+
igps-ble info AA:BB:CC:DD:EE:FF # bateria + device-info
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Uso programático
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
import asyncio
|
|
30
|
+
from igps_ble import IGPSDeviceState
|
|
31
|
+
from igps_ble.scanner import discover_devices
|
|
32
|
+
from igps_ble.client import IGPSClient
|
|
33
|
+
|
|
34
|
+
async def main():
|
|
35
|
+
for dev in await discover_devices(timeout=10):
|
|
36
|
+
state: IGPSDeviceState = await IGPSClient(dev.address, dev.name).async_read_state()
|
|
37
|
+
print(state)
|
|
38
|
+
|
|
39
|
+
asyncio.run(main())
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Escopo
|
|
43
|
+
|
|
44
|
+
| Dado | Suportado | Fonte |
|
|
45
|
+
|------|-----------|-------|
|
|
46
|
+
| Presença | ✅ | advertisement BLE |
|
|
47
|
+
| Bateria % | ✅* | Battery Service `0x180F` |
|
|
48
|
+
| Modelo / firmware | ✅* | Device Information `0x180A` |
|
|
49
|
+
| Velocidade / distância / pedalada | ❌ | sync proprietário (não exposto por BLE) |
|
|
50
|
+
|
|
51
|
+
\* depende de o iGS10S expor o serviço SIG correspondente — confirmado por recon.
|
|
52
|
+
|
|
53
|
+
## Desenvolvimento
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
python -m venv .venv && .venv/bin/pip install -e ".[dev]"
|
|
57
|
+
.venv/bin/pytest
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Licença
|
|
61
|
+
|
|
62
|
+
MIT
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "python-igps-ble"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Pure-Python BLE library for the iGPSPORT iGS10S cycling computer (presence, battery, device info)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [{ name = "Hudson Brendon", email = "contato.hudsonbrendon@gmail.com" }]
|
|
13
|
+
dependencies = ["bleak>=0.22"]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://github.com/hudsonbrendon/python-igps-ble"
|
|
17
|
+
Issues = "https://github.com/hudsonbrendon/python-igps-ble/issues"
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.23", "ruff>=0.5"]
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
igps-ble = "igps_ble.__main__:main"
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["src"]
|
|
27
|
+
|
|
28
|
+
[tool.pytest.ini_options]
|
|
29
|
+
asyncio_mode = "auto"
|
|
30
|
+
pythonpath = ["src"]
|
|
31
|
+
|
|
32
|
+
[tool.ruff]
|
|
33
|
+
target-version = "py311"
|
|
34
|
+
line-length = 88
|
|
35
|
+
|
|
36
|
+
[tool.ruff.lint]
|
|
37
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""CLI: `igps-ble scan` e `igps-ble info <ADDRESS>`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
|
|
8
|
+
from .client import IGPSClient
|
|
9
|
+
from .scanner import discover_devices
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def _scan(timeout: float) -> None:
|
|
13
|
+
devices = await discover_devices(timeout=timeout)
|
|
14
|
+
if not devices:
|
|
15
|
+
print("Nenhum iGPSPORT encontrado. Ligue o aparelho e aproxime.")
|
|
16
|
+
return
|
|
17
|
+
for d in devices:
|
|
18
|
+
print(f"{d.address} rssi={d.rssi} name={d.name}")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def _info(address: str) -> None:
|
|
22
|
+
state = await IGPSClient(address).async_read_state()
|
|
23
|
+
print(f"Endereço: {state.address}")
|
|
24
|
+
print(f"Modelo: {state.model}")
|
|
25
|
+
print(f"Firmware: {state.firmware}")
|
|
26
|
+
print(f"Fabricante: {state.manufacturer}")
|
|
27
|
+
print(f"Bateria: {state.battery_level}%")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main() -> None:
|
|
31
|
+
parser = argparse.ArgumentParser(prog="igps-ble")
|
|
32
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
33
|
+
p_scan = sub.add_parser("scan", help="Procura aparelhos iGPSPORT por perto")
|
|
34
|
+
p_scan.add_argument("--timeout", type=float, default=10.0)
|
|
35
|
+
p_info = sub.add_parser("info", help="Conecta e lê bateria/device-info")
|
|
36
|
+
p_info.add_argument("address")
|
|
37
|
+
|
|
38
|
+
args = parser.parse_args()
|
|
39
|
+
if args.cmd == "scan":
|
|
40
|
+
asyncio.run(_scan(args.timeout))
|
|
41
|
+
elif args.cmd == "info":
|
|
42
|
+
asyncio.run(_info(args.address))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
main()
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Client async que conecta no iGS10S e lê as characteristics em escopo.
|
|
2
|
+
|
|
3
|
+
Único módulo que abre conexão BLE. Aceita um BleakClient já criado (ou um
|
|
4
|
+
BLEDevice) — útil pra reaproveitar a conexão do bleak-retry-connector no HA.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from bleak import BleakClient
|
|
10
|
+
|
|
11
|
+
from .const import (
|
|
12
|
+
BATTERY_LEVEL_UUID,
|
|
13
|
+
FIRMWARE_REVISION_UUID,
|
|
14
|
+
HARDWARE_REVISION_UUID,
|
|
15
|
+
MANUFACTURER_NAME_UUID,
|
|
16
|
+
MODEL_NUMBER_UUID,
|
|
17
|
+
SOFTWARE_REVISION_UUID,
|
|
18
|
+
)
|
|
19
|
+
from .models import IGPSDeviceState
|
|
20
|
+
from .parser import decode_device_string, parse_battery_level
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class IGPSClient:
|
|
24
|
+
"""Lê presença/bateria/device-info de um iGS10S via BLE."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self, address: str, name: str = "iGS10S", rssi: int | None = None
|
|
28
|
+
) -> None:
|
|
29
|
+
self._address = address
|
|
30
|
+
self._name = name
|
|
31
|
+
self._rssi = rssi
|
|
32
|
+
|
|
33
|
+
async def async_read_state(self) -> IGPSDeviceState:
|
|
34
|
+
"""Conecta, lê o que o aparelho expõe, e devolve o snapshot."""
|
|
35
|
+
async with BleakClient(self._address) as client:
|
|
36
|
+
battery = await self._read(client, BATTERY_LEVEL_UUID)
|
|
37
|
+
model = await self._read(client, MODEL_NUMBER_UUID)
|
|
38
|
+
# O iGS10S expõe o firmware na Software Revision (0x2A28); outros
|
|
39
|
+
# aparelhos usam a Firmware Revision (0x2A26). Tentamos as duas.
|
|
40
|
+
firmware = await self._read(client, SOFTWARE_REVISION_UUID)
|
|
41
|
+
if firmware is None:
|
|
42
|
+
firmware = await self._read(client, FIRMWARE_REVISION_UUID)
|
|
43
|
+
hardware = await self._read(client, HARDWARE_REVISION_UUID)
|
|
44
|
+
manufacturer = await self._read(client, MANUFACTURER_NAME_UUID)
|
|
45
|
+
|
|
46
|
+
return IGPSDeviceState(
|
|
47
|
+
address=self._address,
|
|
48
|
+
name=self._name,
|
|
49
|
+
rssi=self._rssi,
|
|
50
|
+
battery_level=parse_battery_level(battery) if battery is not None else None,
|
|
51
|
+
model=decode_device_string(model) if model is not None else None,
|
|
52
|
+
firmware=decode_device_string(firmware) if firmware is not None else None,
|
|
53
|
+
hardware=decode_device_string(hardware) if hardware is not None else None,
|
|
54
|
+
manufacturer=(
|
|
55
|
+
decode_device_string(manufacturer)
|
|
56
|
+
if manufacturer is not None
|
|
57
|
+
else None
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
async def _read(client: BleakClient, uuid: str) -> bytes | None:
|
|
63
|
+
"""Lê a char se ela existir; None se o aparelho não a expõe."""
|
|
64
|
+
if client.services.get_characteristic(uuid) is None:
|
|
65
|
+
return None
|
|
66
|
+
try:
|
|
67
|
+
return await client.read_gatt_char(uuid)
|
|
68
|
+
except Exception: # noqa: BLE001 - char some/erra => trata como ausente
|
|
69
|
+
return None
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""UUIDs Bluetooth SIG e defaults do iGPSPORT iGS10S.
|
|
2
|
+
|
|
3
|
+
UUIDs confirmados no recon (docs/RECON_FINDINGS.md). Ajuste NAME_PREFIXES
|
|
4
|
+
para o local_name exato observado na Task 1.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# Battery Service / characteristic (Bluetooth SIG).
|
|
8
|
+
BATTERY_SERVICE_UUID = "0000180f-0000-1000-8000-00805f9b34fb"
|
|
9
|
+
BATTERY_LEVEL_UUID = "00002a19-0000-1000-8000-00805f9b34fb"
|
|
10
|
+
|
|
11
|
+
# Device Information Service / characteristics (Bluetooth SIG).
|
|
12
|
+
DEVICE_INFO_SERVICE_UUID = "0000180a-0000-1000-8000-00805f9b34fb"
|
|
13
|
+
MODEL_NUMBER_UUID = "00002a24-0000-1000-8000-00805f9b34fb"
|
|
14
|
+
FIRMWARE_REVISION_UUID = "00002a26-0000-1000-8000-00805f9b34fb"
|
|
15
|
+
HARDWARE_REVISION_UUID = "00002a27-0000-1000-8000-00805f9b34fb"
|
|
16
|
+
# O iGS10S reporta o firmware na Software Revision (0x2A28), não na
|
|
17
|
+
# Firmware Revision (0x2A26, ausente). Confirmado no recon 2026-05-31.
|
|
18
|
+
SOFTWARE_REVISION_UUID = "00002a28-0000-1000-8000-00805f9b34fb"
|
|
19
|
+
MANUFACTURER_NAME_UUID = "00002a29-0000-1000-8000-00805f9b34fb"
|
|
20
|
+
|
|
21
|
+
# Prefixos de local_name usados pra reconhecer o aparelho no advertisement.
|
|
22
|
+
# Confirme/ajuste com o que a Task 1 observou.
|
|
23
|
+
NAME_PREFIXES = ("iGS10S", "iGPSPORT", "iGS")
|
|
24
|
+
|
|
25
|
+
MANUFACTURER = "iGPSPORT"
|
|
26
|
+
DEFAULT_MODEL = "iGS10S"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Modelo imutável do estado lido do iGS10S."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class IGPSDeviceState:
|
|
10
|
+
"""Snapshot do que a lib conseguiu ler do aparelho.
|
|
11
|
+
|
|
12
|
+
Campos opcionais ficam None quando o aparelho não expõe a characteristic
|
|
13
|
+
correspondente (ex.: sem Battery Service => battery_level None).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
address: str
|
|
17
|
+
name: str
|
|
18
|
+
rssi: int | None = None
|
|
19
|
+
battery_level: int | None = None
|
|
20
|
+
model: str | None = None
|
|
21
|
+
firmware: str | None = None
|
|
22
|
+
hardware: str | None = None
|
|
23
|
+
manufacturer: str | None = None
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Parsers puros (sem I/O) para os bytes das characteristics SIG."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def parse_battery_level(data: bytes) -> int | None:
|
|
7
|
+
"""Battery Level (0x2A19): 1 byte uint8, 0..100. None se ausente/inválido."""
|
|
8
|
+
if not data:
|
|
9
|
+
return None
|
|
10
|
+
value = data[0]
|
|
11
|
+
if value > 100:
|
|
12
|
+
return None
|
|
13
|
+
return value
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def decode_device_string(data: bytes) -> str | None:
|
|
17
|
+
"""Device Information string UTF-8; remove NUL de padding e espaços."""
|
|
18
|
+
if not data:
|
|
19
|
+
return None
|
|
20
|
+
text = data.decode("utf-8", errors="replace").replace("\x00", "").strip()
|
|
21
|
+
return text or None
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Descoberta BLE do iGS10S (toca o adaptador Bluetooth)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from bleak import BleakScanner
|
|
6
|
+
|
|
7
|
+
from .const import NAME_PREFIXES
|
|
8
|
+
from .models import IGPSDeviceState
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def is_igps_device(local_name: str | None) -> bool:
|
|
12
|
+
"""True se o nome de advertisement bate com um prefixo conhecido do iGPSPORT."""
|
|
13
|
+
if not local_name:
|
|
14
|
+
return False
|
|
15
|
+
return any(local_name.startswith(prefix) for prefix in NAME_PREFIXES)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def discover_devices(timeout: float = 10.0) -> list[IGPSDeviceState]:
|
|
19
|
+
"""Escaneia e devolve um IGPSDeviceState por aparelho iGPSPORT visto.
|
|
20
|
+
|
|
21
|
+
Não conecta — campos de bateria/device-info ficam None aqui.
|
|
22
|
+
"""
|
|
23
|
+
discovered = await BleakScanner.discover(timeout=timeout, return_adv=True)
|
|
24
|
+
results: list[IGPSDeviceState] = []
|
|
25
|
+
for address, (device, adv) in discovered.items():
|
|
26
|
+
name = adv.local_name or device.name
|
|
27
|
+
if not is_igps_device(name):
|
|
28
|
+
continue
|
|
29
|
+
results.append(
|
|
30
|
+
IGPSDeviceState(address=address, name=name, rssi=adv.rssi)
|
|
31
|
+
)
|
|
32
|
+
return results
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-igps-ble
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pure-Python BLE library for the iGPSPORT iGS10S cycling computer (presence, battery, device info)
|
|
5
|
+
Author-email: Hudson Brendon <contato.hudsonbrendon@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/hudsonbrendon/python-igps-ble
|
|
8
|
+
Project-URL: Issues, https://github.com/hudsonbrendon/python-igps-ble/issues
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: bleak>=0.22
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
14
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
15
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
16
|
+
|
|
17
|
+
# python-igps-ble
|
|
18
|
+
|
|
19
|
+
Pure-Python BLE library for the **iGPSPORT iGS10S** cycling computer.
|
|
20
|
+
|
|
21
|
+
Reads, over standard Bluetooth Low Energy, what the device exposes to
|
|
22
|
+
third parties: **presence**, **battery level**, and **device info**
|
|
23
|
+
(model / firmware / manufacturer). The proprietary `.fit` sync used by the
|
|
24
|
+
official iGPSPORT app is **out of scope** (see `ha-igps-ble/docs/PROTOCOL.md`).
|
|
25
|
+
|
|
26
|
+
> Built to back the [`ha-igps-ble`](https://github.com/hudsonbrendon/ha-igps-ble)
|
|
27
|
+
> Home Assistant integration, but usable standalone.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install python-igps-ble
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## CLI
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
igps-ble scan # procura aparelhos iGPSPORT por perto
|
|
39
|
+
igps-ble info AA:BB:CC:DD:EE:FF # bateria + device-info
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Uso programático
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import asyncio
|
|
46
|
+
from igps_ble import IGPSDeviceState
|
|
47
|
+
from igps_ble.scanner import discover_devices
|
|
48
|
+
from igps_ble.client import IGPSClient
|
|
49
|
+
|
|
50
|
+
async def main():
|
|
51
|
+
for dev in await discover_devices(timeout=10):
|
|
52
|
+
state: IGPSDeviceState = await IGPSClient(dev.address, dev.name).async_read_state()
|
|
53
|
+
print(state)
|
|
54
|
+
|
|
55
|
+
asyncio.run(main())
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Escopo
|
|
59
|
+
|
|
60
|
+
| Dado | Suportado | Fonte |
|
|
61
|
+
|------|-----------|-------|
|
|
62
|
+
| Presença | ✅ | advertisement BLE |
|
|
63
|
+
| Bateria % | ✅* | Battery Service `0x180F` |
|
|
64
|
+
| Modelo / firmware | ✅* | Device Information `0x180A` |
|
|
65
|
+
| Velocidade / distância / pedalada | ❌ | sync proprietário (não exposto por BLE) |
|
|
66
|
+
|
|
67
|
+
\* depende de o iGS10S expor o serviço SIG correspondente — confirmado por recon.
|
|
68
|
+
|
|
69
|
+
## Desenvolvimento
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
python -m venv .venv && .venv/bin/pip install -e ".[dev]"
|
|
73
|
+
.venv/bin/pytest
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Licença
|
|
77
|
+
|
|
78
|
+
MIT
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/igps_ble/__init__.py
|
|
4
|
+
src/igps_ble/__main__.py
|
|
5
|
+
src/igps_ble/client.py
|
|
6
|
+
src/igps_ble/const.py
|
|
7
|
+
src/igps_ble/models.py
|
|
8
|
+
src/igps_ble/parser.py
|
|
9
|
+
src/igps_ble/scanner.py
|
|
10
|
+
src/python_igps_ble.egg-info/PKG-INFO
|
|
11
|
+
src/python_igps_ble.egg-info/SOURCES.txt
|
|
12
|
+
src/python_igps_ble.egg-info/dependency_links.txt
|
|
13
|
+
src/python_igps_ble.egg-info/entry_points.txt
|
|
14
|
+
src/python_igps_ble.egg-info/requires.txt
|
|
15
|
+
src/python_igps_ble.egg-info/top_level.txt
|
|
16
|
+
tests/test_client.py
|
|
17
|
+
tests/test_parser.py
|
|
18
|
+
tests/test_scanner.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
igps_ble
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from igps_ble.client import IGPSClient
|
|
6
|
+
from igps_ble.const import (
|
|
7
|
+
BATTERY_LEVEL_UUID,
|
|
8
|
+
FIRMWARE_REVISION_UUID,
|
|
9
|
+
HARDWARE_REVISION_UUID,
|
|
10
|
+
MANUFACTURER_NAME_UUID,
|
|
11
|
+
MODEL_NUMBER_UUID,
|
|
12
|
+
SOFTWARE_REVISION_UUID,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _fake_bleak_client(reads):
|
|
17
|
+
"""BleakClient mock: services presentes p/ os UUIDs em `reads`, reads roteados."""
|
|
18
|
+
|
|
19
|
+
async def read_gatt_char(uuid):
|
|
20
|
+
if uuid not in reads:
|
|
21
|
+
raise KeyError(uuid)
|
|
22
|
+
return reads[uuid]
|
|
23
|
+
|
|
24
|
+
client = MagicMock()
|
|
25
|
+
client.__aenter__ = AsyncMock(return_value=client)
|
|
26
|
+
client.__aexit__ = AsyncMock(return_value=False)
|
|
27
|
+
client.connect = AsyncMock()
|
|
28
|
+
client.disconnect = AsyncMock()
|
|
29
|
+
client.read_gatt_char = AsyncMock(side_effect=read_gatt_char)
|
|
30
|
+
services = MagicMock()
|
|
31
|
+
services.get_characteristic = lambda uuid: object() if uuid in reads else None
|
|
32
|
+
client.services = services
|
|
33
|
+
return client
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _igs10s_reads():
|
|
37
|
+
"""O que o iGS10S real expõe (recon 2026-05-31): sem bateria, sem model,
|
|
38
|
+
firmware na Software Revision (0x2A28), hardware na 0x2A27."""
|
|
39
|
+
return {
|
|
40
|
+
MODEL_NUMBER_UUID: b"iGS10S\x00",
|
|
41
|
+
SOFTWARE_REVISION_UUID: b"V1.15",
|
|
42
|
+
HARDWARE_REVISION_UUID: b"V1.00",
|
|
43
|
+
MANUFACTURER_NAME_UUID: b"iGPSPORT",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.mark.asyncio
|
|
48
|
+
async def test_read_state_firmware_from_software_revision():
|
|
49
|
+
fake = _fake_bleak_client(_igs10s_reads())
|
|
50
|
+
with patch("igps_ble.client.BleakClient", return_value=fake):
|
|
51
|
+
client = IGPSClient("AA:BB:CC:DD:EE:FF", name="iGS10S", rssi=-44)
|
|
52
|
+
state = await client.async_read_state()
|
|
53
|
+
|
|
54
|
+
assert state.firmware == "V1.15" # vem do 0x2A28
|
|
55
|
+
assert state.hardware == "V1.00" # vem do 0x2A27
|
|
56
|
+
assert state.model == "iGS10S"
|
|
57
|
+
assert state.manufacturer == "iGPSPORT"
|
|
58
|
+
assert state.rssi == -44
|
|
59
|
+
assert state.battery_level is None # iGS10S não tem Battery Service
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@pytest.mark.asyncio
|
|
63
|
+
async def test_read_state_firmware_falls_back_to_2a26():
|
|
64
|
+
# Aparelho que usa a Firmware Revision (0x2A26) clássica, sem 0x2A28.
|
|
65
|
+
reads = {
|
|
66
|
+
FIRMWARE_REVISION_UUID: b"2.0.1",
|
|
67
|
+
MANUFACTURER_NAME_UUID: b"iGPSPORT",
|
|
68
|
+
}
|
|
69
|
+
fake = _fake_bleak_client(reads)
|
|
70
|
+
with patch("igps_ble.client.BleakClient", return_value=fake):
|
|
71
|
+
client = IGPSClient("AA:BB:CC:DD:EE:FF")
|
|
72
|
+
state = await client.async_read_state()
|
|
73
|
+
|
|
74
|
+
assert state.firmware == "2.0.1"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@pytest.mark.asyncio
|
|
78
|
+
async def test_read_state_battery_present_is_parsed():
|
|
79
|
+
reads = {**_igs10s_reads(), BATTERY_LEVEL_UUID: bytes([0x57])}
|
|
80
|
+
fake = _fake_bleak_client(reads)
|
|
81
|
+
with patch("igps_ble.client.BleakClient", return_value=fake):
|
|
82
|
+
client = IGPSClient("AA:BB:CC:DD:EE:FF")
|
|
83
|
+
state = await client.async_read_state()
|
|
84
|
+
|
|
85
|
+
assert state.battery_level == 87
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@pytest.mark.asyncio
|
|
89
|
+
async def test_read_state_missing_model_is_none():
|
|
90
|
+
reads = {
|
|
91
|
+
SOFTWARE_REVISION_UUID: b"V1.15",
|
|
92
|
+
MANUFACTURER_NAME_UUID: b"iGPSPORT",
|
|
93
|
+
}
|
|
94
|
+
fake = _fake_bleak_client(reads)
|
|
95
|
+
with patch("igps_ble.client.BleakClient", return_value=fake):
|
|
96
|
+
client = IGPSClient("AA:BB:CC:DD:EE:FF", name="iGS10S")
|
|
97
|
+
state = await client.async_read_state()
|
|
98
|
+
|
|
99
|
+
assert state.model is None
|
|
100
|
+
assert state.firmware == "V1.15"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from igps_ble.parser import decode_device_string, parse_battery_level
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_battery_level_reads_single_byte_percent():
|
|
5
|
+
# Padrão SIG 0x2A19: 1 byte uint8 = porcentagem. 0x57 = 87%.
|
|
6
|
+
assert parse_battery_level(bytes([0x57])) == 87
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_battery_level_zero_and_full():
|
|
10
|
+
assert parse_battery_level(bytes([0x00])) == 0
|
|
11
|
+
assert parse_battery_level(bytes([0x64])) == 100
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_battery_level_empty_returns_none():
|
|
15
|
+
assert parse_battery_level(b"") is None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_battery_level_out_of_range_clamped():
|
|
19
|
+
# Alguns aparelhos devolvem 0xFF ("desconhecido"); tratamos como None.
|
|
20
|
+
assert parse_battery_level(bytes([0xFF])) is None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_decode_device_string_strips_nul_and_whitespace():
|
|
24
|
+
# Strings do Device Info vêm UTF-8, às vezes com NUL de padding.
|
|
25
|
+
assert decode_device_string(b"iGS10S\x00") == "iGS10S"
|
|
26
|
+
assert decode_device_string(b" V1.20 \x00") == "V1.20"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_decode_device_string_empty_returns_none():
|
|
30
|
+
assert decode_device_string(b"") is None
|
|
31
|
+
assert decode_device_string(b"\x00\x00") is None
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from types import SimpleNamespace
|
|
2
|
+
from unittest.mock import AsyncMock, patch
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from igps_ble.scanner import discover_devices, is_igps_device
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _adv(local_name, rssi=-50, service_uuids=None):
|
|
10
|
+
return SimpleNamespace(
|
|
11
|
+
local_name=local_name, rssi=rssi, service_uuids=service_uuids or []
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_is_igps_device_matches_known_prefixes():
|
|
16
|
+
assert is_igps_device("iGS10S") is True
|
|
17
|
+
assert is_igps_device("iGPSPORT iGS10S") is True
|
|
18
|
+
assert is_igps_device("iGS520") is True
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_is_igps_device_rejects_others():
|
|
22
|
+
assert is_igps_device("JBL Charge 5") is False
|
|
23
|
+
assert is_igps_device("") is False
|
|
24
|
+
assert is_igps_device(None) is False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.mark.asyncio
|
|
28
|
+
async def test_discover_devices_filters_and_maps():
|
|
29
|
+
bledev = SimpleNamespace(address="AA:BB:CC:DD:EE:FF", name="iGS10S")
|
|
30
|
+
other = SimpleNamespace(address="11:22:33:44:55:66", name="TV")
|
|
31
|
+
discovered = {
|
|
32
|
+
"AA:BB:CC:DD:EE:FF": (bledev, _adv("iGS10S", rssi=-44)),
|
|
33
|
+
"11:22:33:44:55:66": (other, _adv("TV", rssi=-70)),
|
|
34
|
+
}
|
|
35
|
+
with patch(
|
|
36
|
+
"igps_ble.scanner.BleakScanner.discover",
|
|
37
|
+
new=AsyncMock(return_value=discovered),
|
|
38
|
+
):
|
|
39
|
+
results = await discover_devices(timeout=1.0)
|
|
40
|
+
|
|
41
|
+
assert len(results) == 1
|
|
42
|
+
state = results[0]
|
|
43
|
+
assert state.address == "AA:BB:CC:DD:EE:FF"
|
|
44
|
+
assert state.name == "iGS10S"
|
|
45
|
+
assert state.rssi == -44
|
|
46
|
+
assert state.battery_level is None # scan não conecta
|