sok-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.
sok_ble-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.4
2
+ Name: sok-ble
3
+ Version: 0.1.0
4
+ Summary: SOK BLE battery interface library
5
+ Author-email: Mitchell Carlson <mitchell.carlson.pro@gmail.com>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/IAmTheMitchell/sok-ble
8
+ Project-URL: Bug Tracker, https://github.com/IAmTheMitchell/sok-ble/issues
9
+ Project-URL: Changelog, https://github.com/IAmTheMitchell/sok-ble/blob/main/CHANGELOG.md
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: Apache Software License
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.11
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: bleak>=0.22.3
16
+ Requires-Dist: bleak-retry-connector>=3.10.0
17
+
18
+ # SOK BLE
19
+
20
+ ![CI](https://github.com/IAmTheMitchell/sok-ble/actions/workflows/ci.yml/badge.svg)
21
+
22
+ Python library for interacting with SOK Bluetooth-enabled batteries.
23
+
24
+ ## Quick Start
25
+
26
+ ```python
27
+ import asyncio
28
+ from bleak import BleakScanner
29
+ from sok_ble.sok_bluetooth_device import SokBluetoothDevice
30
+
31
+
32
+ async def main() -> None:
33
+ device = await BleakScanner.find_device_by_address("AA:BB:CC:DD:EE:FF")
34
+ sok = SokBluetoothDevice(device)
35
+ await sok.async_update()
36
+ print("Voltage:", sok.voltage)
37
+
38
+
39
+ asyncio.run(main())
40
+ ```
@@ -0,0 +1,23 @@
1
+ # SOK BLE
2
+
3
+ ![CI](https://github.com/IAmTheMitchell/sok-ble/actions/workflows/ci.yml/badge.svg)
4
+
5
+ Python library for interacting with SOK Bluetooth-enabled batteries.
6
+
7
+ ## Quick Start
8
+
9
+ ```python
10
+ import asyncio
11
+ from bleak import BleakScanner
12
+ from sok_ble.sok_bluetooth_device import SokBluetoothDevice
13
+
14
+
15
+ async def main() -> None:
16
+ device = await BleakScanner.find_device_by_address("AA:BB:CC:DD:EE:FF")
17
+ sok = SokBluetoothDevice(device)
18
+ await sok.async_update()
19
+ print("Voltage:", sok.voltage)
20
+
21
+
22
+ asyncio.run(main())
23
+ ```
@@ -0,0 +1,48 @@
1
+ [project]
2
+ name = "sok-ble"
3
+ version = "0.1.0"
4
+ description = "SOK BLE battery interface library"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "bleak>=0.22.3",
9
+ "bleak-retry-connector>=3.10.0",
10
+ ]
11
+ authors = [
12
+ {name = "Mitchell Carlson", email = "mitchell.carlson.pro@gmail.com"}
13
+ ]
14
+ license = {text = "Apache-2.0"}
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "License :: OSI Approved :: Apache Software License",
18
+ "Operating System :: OS Independent",
19
+ ]
20
+
21
+ [project.urls]
22
+ "Homepage" = "https://github.com/IAmTheMitchell/sok-ble"
23
+ "Bug Tracker" = "https://github.com/IAmTheMitchell/sok-ble/issues"
24
+ "Changelog" = "https://github.com/IAmTheMitchell/sok-ble/blob/main/CHANGELOG.md"
25
+
26
+ [tool.setuptools]
27
+ license-files = []
28
+ # Fix from https://github.com/astral-sh/uv/issues/9513#issuecomment-2519527822
29
+
30
+ [build-system]
31
+ requires = ["setuptools>=61.0"]
32
+ build-backend = "setuptools.build_meta"
33
+
34
+ [dependency-groups]
35
+ dev = [
36
+ "pytest>=7.0.1",
37
+ "pytest-asyncio>=1.0.0",
38
+ ]
39
+
40
+ [tool.semantic_release]
41
+ branch = "main"
42
+ version_toml = ["pyproject.toml:project.version"]
43
+
44
+ [tool.ruff]
45
+ # Ruff configuration placeholder
46
+
47
+ [tool.mypy]
48
+ # Mypy configuration placeholder
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,33 @@
1
+ """Constants and helpers for interacting with SOK BLE batteries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ UUID_RX = "0000ffe1-0000-1000-8000-00805f9b34fb"
6
+ UUID_TX = "0000ffe2-0000-1000-8000-00805f9b34fb"
7
+
8
+ # Command byte templates extracted from the reference addon
9
+ CMD_NAME = [0xEE, 0xC0, 0x00, 0x00, 0x00]
10
+ CMD_INFO = [0xEE, 0xC1, 0x00, 0x00, 0x00]
11
+ CMD_DETAIL = [0xEE, 0xC2, 0x00, 0x00, 0x00]
12
+ CMD_SETTING = [0xEE, 0xC3, 0x00, 0x00, 0x00]
13
+ CMD_PROTECTION = [0xEE, 0xC4, 0x00, 0x00, 0x00]
14
+ CMD_BREAK = [0xDD, 0xC0, 0x00, 0x00, 0x00]
15
+
16
+
17
+ def minicrc(data: list[int] | bytes) -> int:
18
+ """Compute CRC-8 used by the SOK protocol."""
19
+
20
+ crc = 0
21
+ for byte in data:
22
+ crc ^= byte & 0xFF
23
+ for _ in range(8):
24
+ crc = (crc >> 1) ^ 0x8C if (crc & 1) else crc >> 1
25
+ return crc
26
+
27
+
28
+ def _sok_command(cmd: int) -> bytes:
29
+ """Return a command frame with CRC for the given command byte."""
30
+
31
+ data = [0xEE, cmd, 0x00, 0x00, 0x00]
32
+ crc = minicrc(data)
33
+ return bytes(data + [crc])
@@ -0,0 +1,11 @@
1
+ class SokError(Exception):
2
+ """Base for SOK library errors."""
3
+
4
+
5
+ class BLEConnectionError(SokError):
6
+ """Raised when BLE communication fails."""
7
+
8
+
9
+ class InvalidResponseError(SokError):
10
+ """Raised when an invalid response is received."""
11
+
@@ -0,0 +1,160 @@
1
+ """BLE device abstraction for SOK batteries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import asynccontextmanager
6
+ from typing import AsyncIterator, Optional
7
+ import logging
8
+ import statistics
9
+
10
+ from bleak.backends.device import BLEDevice
11
+
12
+ from sok_ble.const import UUID_RX, UUID_TX, _sok_command
13
+ from sok_ble.sok_parser import SokParser
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ try:
18
+ from bleak_retry_connector import (
19
+ BleakClientWithServiceCache,
20
+ establish_connection,
21
+ )
22
+ except Exception: # pragma: no cover - optional dependency
23
+ from bleak import BleakClient as BleakClientWithServiceCache
24
+ establish_connection = None # type: ignore[misc]
25
+
26
+
27
+ class SokBluetoothDevice:
28
+ """Minimal BLE interface for a SOK battery."""
29
+
30
+ def __init__(self, ble_device: BLEDevice, adapter: Optional[str] | None = None) -> None:
31
+ self._ble_device = ble_device
32
+ self._adapter = adapter
33
+
34
+ self.voltage: float | None = None
35
+ self.current: float | None = None
36
+ self.soc: int | None = None
37
+ self.temperature: float | None = None
38
+ self.capacity: float | None = None
39
+ self.num_cycles: int | None = None
40
+ self.cell_voltages: list[float] | None = None
41
+
42
+ # Housekeeping
43
+ self.num_samples = 0
44
+
45
+ @asynccontextmanager
46
+ async def _connect(self) -> AsyncIterator[BleakClientWithServiceCache]:
47
+ """Connect to the device and yield a BLE client."""
48
+ logger.debug("Connecting to %s", self._ble_device.address)
49
+ if establish_connection:
50
+ client = await establish_connection(
51
+ BleakClientWithServiceCache,
52
+ self._ble_device,
53
+ self._ble_device.name or self._ble_device.address,
54
+ adapter=self._adapter,
55
+ )
56
+ else:
57
+ client = BleakClientWithServiceCache(
58
+ self._ble_device,
59
+ adapter=self._adapter,
60
+ )
61
+ await client.connect()
62
+ try:
63
+ yield client
64
+ finally:
65
+ await client.disconnect()
66
+ logger.debug("Disconnected from %s", self._ble_device.address)
67
+
68
+ async def async_update(self) -> None:
69
+ """Poll the device for all telemetry and update attributes."""
70
+ responses: dict[int, bytes] = {}
71
+ async with self._connect() as client:
72
+ logger.debug("Send C1")
73
+ await client.write_gatt_char(UUID_TX, _sok_command(0xC1))
74
+ data = bytes(await client.read_gatt_char(UUID_RX))
75
+ logger.debug("Recv 0xCCF0: %s", data.hex())
76
+ responses[0xCCF0] = data
77
+
78
+ logger.debug("Send C1")
79
+ await client.write_gatt_char(UUID_TX, _sok_command(0xC1))
80
+ data = bytes(await client.read_gatt_char(UUID_RX))
81
+ logger.debug("Recv 0xCCF2: %s", data.hex())
82
+ responses[0xCCF2] = data
83
+
84
+ logger.debug("Send C2")
85
+ await client.write_gatt_char(UUID_TX, _sok_command(0xC2))
86
+ data = bytes(await client.read_gatt_char(UUID_RX))
87
+ logger.debug("Recv 0xCCF3: %s", data.hex())
88
+ responses[0xCCF3] = data
89
+
90
+ logger.debug("Send C2")
91
+ await client.write_gatt_char(UUID_TX, _sok_command(0xC2))
92
+ data = bytes(await client.read_gatt_char(UUID_RX))
93
+ logger.debug("Recv 0xCCF4: %s", data.hex())
94
+ responses[0xCCF4] = data
95
+
96
+ parsed = SokParser.parse_all(responses)
97
+ logger.debug("Parsed update: %s", parsed)
98
+
99
+ self.voltage = parsed["voltage"]
100
+ self.current = parsed["current"]
101
+ self.soc = parsed["soc"]
102
+ self.temperature = parsed["temperature"]
103
+ self.capacity = parsed["capacity"]
104
+ self.num_cycles = parsed["num_cycles"]
105
+ self.cell_voltages = parsed["cell_voltages"]
106
+
107
+ self.num_samples += 1
108
+
109
+ # Derived metrics -----------------------------------------------------
110
+
111
+ @property
112
+ def power(self) -> float | None:
113
+ """Return instantaneous power in watts."""
114
+ if self.voltage is None or self.current is None:
115
+ return None
116
+ return self.voltage * self.current
117
+
118
+ @property
119
+ def cell_voltage_max(self) -> float | None:
120
+ cells = self.cell_voltages
121
+ return max(cells) if cells else None
122
+
123
+ @property
124
+ def cell_voltage_min(self) -> float | None:
125
+ cells = self.cell_voltages
126
+ return min(cells) if cells else None
127
+
128
+ @property
129
+ def cell_voltage_avg(self) -> float | None:
130
+ cells = self.cell_voltages
131
+ if not cells:
132
+ return None
133
+ return sum(cells) / len(cells)
134
+
135
+ @property
136
+ def cell_voltage_median(self) -> float | None:
137
+ cells = self.cell_voltages
138
+ if not cells:
139
+ return None
140
+ return statistics.median(cells)
141
+
142
+ @property
143
+ def cell_voltage_delta(self) -> float | None:
144
+ if self.cell_voltage_max is None or self.cell_voltage_min is None:
145
+ return None
146
+ return self.cell_voltage_max - self.cell_voltage_min
147
+
148
+ @property
149
+ def cell_index_max(self) -> int | None:
150
+ cells = self.cell_voltages
151
+ if not cells:
152
+ return None
153
+ return cells.index(max(cells))
154
+
155
+ @property
156
+ def cell_index_min(self) -> int | None:
157
+ cells = self.cell_voltages
158
+ if not cells:
159
+ return None
160
+ return cells.index(min(cells))
@@ -0,0 +1,135 @@
1
+ """Parsing utilities for SOK BLE responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import struct
7
+ from typing import Dict, Sequence
8
+
9
+ from sok_ble.exceptions import InvalidResponseError
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ # Endian helper functions copied from the reference addon
15
+
16
+ def get_le_short(data: Sequence[int] | bytes | bytearray, offset: int) -> int:
17
+ """Read a little-endian signed short."""
18
+ return struct.unpack_from('<h', bytes(data), offset)[0]
19
+
20
+
21
+ def get_le_ushort(data: Sequence[int] | bytes | bytearray, offset: int) -> int:
22
+ """Read a little-endian unsigned short."""
23
+ return struct.unpack_from('<H', bytes(data), offset)[0]
24
+
25
+
26
+ def get_le_int3(data: Sequence[int] | bytes | bytearray, offset: int) -> int:
27
+ """Read a 3-byte little-endian signed integer."""
28
+ b0, b1, b2 = bytes(data)[offset:offset + 3]
29
+ val = b0 | (b1 << 8) | (b2 << 16)
30
+ if val & 0x800000:
31
+ val -= 0x1000000
32
+ return val
33
+
34
+
35
+ def get_be_uint3(data: Sequence[int] | bytes | bytearray, offset: int) -> int:
36
+ """Read a 3-byte big-endian unsigned integer."""
37
+ b0, b1, b2 = bytes(data)[offset:offset + 3]
38
+ return (b0 << 16) | (b1 << 8) | b2
39
+
40
+
41
+ class SokParser:
42
+ """Parse buffers returned from SOK batteries."""
43
+
44
+ @staticmethod
45
+ def parse_info(buf: bytes) -> Dict[str, float | int]:
46
+ """Parse the information frame for voltage, current and SOC."""
47
+ logger.debug("parse_info input: %s", buf.hex())
48
+ if len(buf) < 18:
49
+ raise InvalidResponseError("Info buffer too short")
50
+
51
+ cells = [
52
+ get_le_ushort(buf, 0),
53
+ get_le_ushort(buf, 2),
54
+ get_le_ushort(buf, 4),
55
+ get_le_ushort(buf, 6),
56
+ ]
57
+ voltage = (sum(cells) / len(cells) * 4) / 1000
58
+
59
+ current = get_le_int3(buf, 8) / 10
60
+
61
+ soc = struct.unpack_from('<H', buf, 16)[0]
62
+
63
+ result = {
64
+ "voltage": voltage,
65
+ "current": current,
66
+ "soc": soc,
67
+ }
68
+ logger.debug("parse_info result: %s", result)
69
+ return result
70
+
71
+ @staticmethod
72
+ def parse_temps(buf: bytes) -> float:
73
+ """Parse the temperature from the temperature frame."""
74
+ logger.debug("parse_temps input: %s", buf.hex())
75
+ if len(buf) < 7:
76
+ raise InvalidResponseError("Temp buffer too short")
77
+
78
+ temperature = get_le_short(buf, 5) / 10
79
+ logger.debug("parse_temps result: %s", temperature)
80
+ return temperature
81
+
82
+ @staticmethod
83
+ def parse_capacity_cycles(buf: bytes) -> Dict[str, float | int]:
84
+ """Parse rated capacity and cycle count."""
85
+ logger.debug("parse_capacity_cycles input: %s", buf.hex())
86
+ if len(buf) < 6:
87
+ raise InvalidResponseError("Capacity buffer too short")
88
+
89
+ capacity = get_le_ushort(buf, 0) / 100
90
+ num_cycles = get_le_ushort(buf, 4)
91
+
92
+ result = {
93
+ "capacity": capacity,
94
+ "num_cycles": num_cycles,
95
+ }
96
+ logger.debug("parse_capacity_cycles result: %s", result)
97
+ return result
98
+
99
+ @staticmethod
100
+ def parse_cells(buf: bytes) -> list[float]:
101
+ """Parse individual cell voltages."""
102
+ logger.debug("parse_cells input: %s", buf.hex())
103
+ if len(buf) < 8:
104
+ raise InvalidResponseError("Cells buffer too short")
105
+
106
+ cells = [
107
+ get_le_ushort(buf, 0) / 1000,
108
+ get_le_ushort(buf, 2) / 1000,
109
+ get_le_ushort(buf, 4) / 1000,
110
+ get_le_ushort(buf, 6) / 1000,
111
+ ]
112
+ logger.debug("parse_cells result: %s", cells)
113
+ return cells
114
+
115
+ @classmethod
116
+ def parse_all(cls, responses: Dict[int, bytes]) -> Dict[str, float | int | list[float]]:
117
+ """Parse all response buffers into a single dictionary."""
118
+ logger.debug("parse_all input keys: %s", list(responses))
119
+ required = {0xCCF0, 0xCCF2, 0xCCF3, 0xCCF4}
120
+ if not required.issubset(responses):
121
+ raise InvalidResponseError("Missing response buffers")
122
+
123
+ info = cls.parse_info(responses[0xCCF0])
124
+ temperature = cls.parse_temps(responses[0xCCF2])
125
+ capacity_info = cls.parse_capacity_cycles(responses[0xCCF3])
126
+ cells = cls.parse_cells(responses[0xCCF4])
127
+
128
+ result = {
129
+ **info,
130
+ "temperature": temperature,
131
+ **capacity_info,
132
+ "cell_voltages": cells,
133
+ }
134
+ logger.debug("parse_all result: %s", result)
135
+ return result
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.4
2
+ Name: sok-ble
3
+ Version: 0.1.0
4
+ Summary: SOK BLE battery interface library
5
+ Author-email: Mitchell Carlson <mitchell.carlson.pro@gmail.com>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/IAmTheMitchell/sok-ble
8
+ Project-URL: Bug Tracker, https://github.com/IAmTheMitchell/sok-ble/issues
9
+ Project-URL: Changelog, https://github.com/IAmTheMitchell/sok-ble/blob/main/CHANGELOG.md
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: Apache Software License
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.11
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: bleak>=0.22.3
16
+ Requires-Dist: bleak-retry-connector>=3.10.0
17
+
18
+ # SOK BLE
19
+
20
+ ![CI](https://github.com/IAmTheMitchell/sok-ble/actions/workflows/ci.yml/badge.svg)
21
+
22
+ Python library for interacting with SOK Bluetooth-enabled batteries.
23
+
24
+ ## Quick Start
25
+
26
+ ```python
27
+ import asyncio
28
+ from bleak import BleakScanner
29
+ from sok_ble.sok_bluetooth_device import SokBluetoothDevice
30
+
31
+
32
+ async def main() -> None:
33
+ device = await BleakScanner.find_device_by_address("AA:BB:CC:DD:EE:FF")
34
+ sok = SokBluetoothDevice(device)
35
+ await sok.async_update()
36
+ print("Voltage:", sok.voltage)
37
+
38
+
39
+ asyncio.run(main())
40
+ ```
@@ -0,0 +1,19 @@
1
+ README.md
2
+ pyproject.toml
3
+ sok_ble/__init__.py
4
+ sok_ble/const.py
5
+ sok_ble/exceptions.py
6
+ sok_ble/sok_bluetooth_device.py
7
+ sok_ble/sok_parser.py
8
+ sok_ble.egg-info/PKG-INFO
9
+ sok_ble.egg-info/SOURCES.txt
10
+ sok_ble.egg-info/dependency_links.txt
11
+ sok_ble.egg-info/requires.txt
12
+ sok_ble.egg-info/top_level.txt
13
+ tests/test_const.py
14
+ tests/test_derived.py
15
+ tests/test_device_full.py
16
+ tests/test_device_minimal.py
17
+ tests/test_exceptions.py
18
+ tests/test_parser_full.py
19
+ tests/test_parser_info.py
@@ -0,0 +1,2 @@
1
+ bleak>=0.22.3
2
+ bleak-retry-connector>=3.10.0
@@ -0,0 +1 @@
1
+ sok_ble
@@ -0,0 +1,13 @@
1
+ from sok_ble.const import minicrc, _sok_command
2
+
3
+
4
+ def test_minicrc_known_value():
5
+ data = [0xEE, 0xC1, 0x00, 0x00, 0x00]
6
+ assert minicrc(data) == 0xCE
7
+
8
+
9
+ def test_sok_command_crc_and_length():
10
+ cmd = _sok_command(0xC1)
11
+ assert len(cmd) == 6
12
+ # CRC byte should match minicrc of base command frame
13
+ assert cmd[-1] == minicrc([0xEE, 0xC1, 0x00, 0x00, 0x00])
@@ -0,0 +1,29 @@
1
+ import pytest
2
+ from bleak.backends.device import BLEDevice
3
+
4
+ from sok_ble.sok_bluetooth_device import SokBluetoothDevice
5
+
6
+
7
+ def make_device():
8
+ return SokBluetoothDevice(BLEDevice("00:11:22:33:44:55", "Test", None, -60))
9
+
10
+
11
+ def test_power_property():
12
+ dev = make_device()
13
+ dev.voltage = 12.5
14
+ dev.current = -10.0
15
+ assert dev.power == pytest.approx(-125.0)
16
+
17
+
18
+ def test_cell_voltage_stats():
19
+ dev = make_device()
20
+ dev.cell_voltages = [3.1, 3.15, 3.05, 3.2]
21
+
22
+ assert dev.cell_voltage_max == 3.2
23
+ assert dev.cell_voltage_min == 3.05
24
+ assert dev.cell_voltage_avg == pytest.approx(3.125)
25
+ assert dev.cell_voltage_median == pytest.approx(3.125)
26
+ assert dev.cell_voltage_delta == pytest.approx(0.15)
27
+ assert dev.cell_index_max == 3
28
+ assert dev.cell_index_min == 2
29
+
@@ -0,0 +1,59 @@
1
+ import pytest
2
+ from bleak.backends.device import BLEDevice
3
+
4
+ from sok_ble import sok_bluetooth_device as device_mod
5
+
6
+
7
+ class DummyClient:
8
+ def __init__(self, responses):
9
+ self._responses = list(responses)
10
+
11
+ async def connect(self):
12
+ return True
13
+
14
+ async def disconnect(self):
15
+ return True
16
+
17
+ async def write_gatt_char(self, *args, **kwargs):
18
+ return True
19
+
20
+ async def read_gatt_char(self, *args, **kwargs):
21
+ return self._responses.pop(0)
22
+
23
+
24
+ @pytest.mark.asyncio
25
+ async def test_full_update(monkeypatch):
26
+ responses = [
27
+ bytes.fromhex(
28
+ "E4 0C E9 0C EE 0C F3 0C 64 00 00 00 00 00 00 00 41 00"
29
+ ),
30
+ bytes.fromhex("00 00 00 00 00 FA 00"),
31
+ bytes.fromhex("10 27 00 00 32 00 00 00"),
32
+ bytes.fromhex("E4 0C E9 0C EE 0C F3 0C"),
33
+ ]
34
+
35
+ dummy = DummyClient(responses)
36
+ monkeypatch.setattr(device_mod, "establish_connection", None, raising=False)
37
+ monkeypatch.setattr(device_mod, "BleakClientWithServiceCache", lambda *a, **k: dummy)
38
+
39
+ dev = device_mod.SokBluetoothDevice(
40
+ BLEDevice("00:11:22:33:44:55", "Test", None, -60)
41
+ )
42
+
43
+ await dev.async_update()
44
+
45
+ assert dev.voltage == 13.23
46
+ assert dev.current == 10.0
47
+ assert dev.soc == 65
48
+ assert dev.temperature == 25.0
49
+ assert dev.capacity == 100.0
50
+ assert dev.num_cycles == 50
51
+ assert dev.cell_voltages == [3.3, 3.305, 3.31, 3.315]
52
+ assert dev.power == pytest.approx(132.3)
53
+ assert dev.cell_voltage_max == 3.315
54
+ assert dev.cell_voltage_min == 3.3
55
+ assert dev.cell_voltage_avg == pytest.approx(3.3075)
56
+ assert dev.cell_voltage_median == pytest.approx(3.3075)
57
+ assert dev.cell_voltage_delta == pytest.approx(0.015)
58
+ assert dev.cell_index_max == 3
59
+ assert dev.cell_index_min == 0
@@ -0,0 +1,41 @@
1
+ import pytest
2
+
3
+ from bleak.backends.device import BLEDevice
4
+
5
+ from sok_ble import sok_bluetooth_device as device_mod
6
+
7
+
8
+ class DummyClient:
9
+ def __init__(self, *args, **kwargs):
10
+ pass
11
+
12
+ async def connect(self):
13
+ return True
14
+
15
+ async def disconnect(self):
16
+ return True
17
+
18
+ async def write_gatt_char(self, *args, **kwargs):
19
+ return True
20
+
21
+ async def read_gatt_char(self, *args, **kwargs):
22
+ # Response bytes correspond to voltage=13.23V, current=10A, soc=65
23
+ return bytes.fromhex(
24
+ "E4 0C E9 0C EE 0C F3 0C 64 00 00 00 00 00 00 00 41 00"
25
+ )
26
+
27
+
28
+ @pytest.mark.asyncio
29
+ async def test_minimal_update(monkeypatch):
30
+ monkeypatch.setattr(device_mod, "establish_connection", None, raising=False)
31
+ monkeypatch.setattr(device_mod, "BleakClientWithServiceCache", DummyClient)
32
+
33
+ dev = device_mod.SokBluetoothDevice(
34
+ BLEDevice("00:11:22:33:44:55", "Test", None, -60)
35
+ )
36
+
37
+ await dev.async_update()
38
+
39
+ assert dev.voltage == 13.23
40
+ assert dev.current == 10.0
41
+ assert dev.soc == 65
@@ -0,0 +1,19 @@
1
+ import pytest
2
+
3
+ from sok_ble.exceptions import BLEConnectionError, InvalidResponseError, SokError
4
+
5
+
6
+ def test_exception_inheritance():
7
+ assert issubclass(BLEConnectionError, SokError)
8
+ assert issubclass(InvalidResponseError, SokError)
9
+
10
+
11
+ def test_raises_ble_connection_error():
12
+ with pytest.raises(BLEConnectionError):
13
+ raise BLEConnectionError()
14
+
15
+
16
+ def test_raises_invalid_response_error():
17
+ with pytest.raises(InvalidResponseError):
18
+ raise InvalidResponseError()
19
+
@@ -0,0 +1,35 @@
1
+ from sok_ble.sok_parser import SokParser
2
+
3
+
4
+ def test_parse_all():
5
+ info_buf = bytes.fromhex(
6
+ "E4 0C E9 0C EE 0C F3 0C 64 00 00 00 00 00 00 00 41 00"
7
+ )
8
+ temp_buf = bytes.fromhex(
9
+ "00 00 00 00 00 FA 00"
10
+ )
11
+ cap_buf = bytes.fromhex(
12
+ "10 27 00 00 32 00 00 00"
13
+ )
14
+ cell_buf = bytes.fromhex(
15
+ "E4 0C E9 0C EE 0C F3 0C"
16
+ )
17
+
18
+ responses = {
19
+ 0xCCF0: info_buf,
20
+ 0xCCF2: temp_buf,
21
+ 0xCCF3: cap_buf,
22
+ 0xCCF4: cell_buf,
23
+ }
24
+
25
+ result = SokParser.parse_all(responses)
26
+ assert result == {
27
+ "voltage": 13.23,
28
+ "current": 10.0,
29
+ "soc": 65,
30
+ "temperature": 25.0,
31
+ "capacity": 100.0,
32
+ "num_cycles": 50,
33
+ "cell_voltages": [3.3, 3.305, 3.31, 3.315],
34
+ }
35
+
@@ -0,0 +1,24 @@
1
+ from sok_ble.sok_parser import SokParser
2
+
3
+
4
+ def test_parse_info_basic():
5
+ hex_data = bytes.fromhex(
6
+ "E4 0C E9 0C EE 0C F3 0C 64 00 00 00 00 00 00 00 41 00"
7
+ )
8
+ result = SokParser.parse_info(hex_data)
9
+ assert result == {
10
+ "voltage": 13.23,
11
+ "current": 10.0,
12
+ "soc": 65,
13
+ }
14
+
15
+
16
+ def test_parse_info_invalid_length():
17
+ data = b"\x00" * 10
18
+ try:
19
+ SokParser.parse_info(data)
20
+ assert False, "Expected InvalidResponseError"
21
+ except Exception as err:
22
+ from sok_ble.exceptions import InvalidResponseError
23
+
24
+ assert isinstance(err, InvalidResponseError)