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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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__"]
@@ -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,2 @@
1
+ [console_scripts]
2
+ igps-ble = igps_ble.__main__:main
@@ -0,0 +1,6 @@
1
+ bleak>=0.22
2
+
3
+ [dev]
4
+ pytest>=8
5
+ pytest-asyncio>=0.23
6
+ ruff>=0.5
@@ -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