python-igps-ble 0.1.0__py3-none-any.whl

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.
igps_ble/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Pure-Python BLE library for the iGPSPORT iGS10S cycling computer."""
2
+
3
+ from .models import IGPSDeviceState
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = ["IGPSDeviceState", "__version__"]
igps_ble/__main__.py ADDED
@@ -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()
igps_ble/client.py ADDED
@@ -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
igps_ble/const.py ADDED
@@ -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"
igps_ble/models.py ADDED
@@ -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
igps_ble/parser.py ADDED
@@ -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
igps_ble/scanner.py ADDED
@@ -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,12 @@
1
+ igps_ble/__init__.py,sha256=eeV-l5_0sdRkAEPeB9uUUE4G2dZ-rCq1T6y1UJhDWbk,178
2
+ igps_ble/__main__.py,sha256=mCoZbDckv8FDd613fWJE0pTaCVwv2fG3Snn2V6KhWGY,1414
3
+ igps_ble/client.py,sha256=f66p39IP5g-UVkOn30JACZrjOOPuQs5IGJ6_7g665gg,2728
4
+ igps_ble/const.py,sha256=0PfoY4CCFypAJ4bEKyBpFLDZufB6aRZKd04IFdI35sM,1161
5
+ igps_ble/models.py,sha256=H6UNnnMfVovc0EsqYHyC0oIPPvnJ99i8Vi9y_SyWL-I,620
6
+ igps_ble/parser.py,sha256=IkY_fpODaJkXwSilkA5JzXks1VEP5MsoHjf4aOr9JtE,627
7
+ igps_ble/scanner.py,sha256=aJj3bE_K9ky9fJKicKLrRoWf1YSeXZRRsGghpzBauP4,1085
8
+ python_igps_ble-0.1.0.dist-info/METADATA,sha256=QX_XkHtYvVZV8Dg7aNWzuIYMCo7P9bvi8z1aV-QHjcs,2253
9
+ python_igps_ble-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
+ python_igps_ble-0.1.0.dist-info/entry_points.txt,sha256=WmO3pSZ-qD2ZfHtnxxqyvUycW8LEwMFIJIC1k7oFSuY,52
11
+ python_igps_ble-0.1.0.dist-info/top_level.txt,sha256=69O33W2-0wC37bi3yvYxMJqOVdk612_CqJYgxErMS8A,9
12
+ python_igps_ble-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ igps-ble = igps_ble.__main__:main
@@ -0,0 +1 @@
1
+ igps_ble