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 +40 -0
- sok_ble-0.1.0/README.md +23 -0
- sok_ble-0.1.0/pyproject.toml +48 -0
- sok_ble-0.1.0/setup.cfg +4 -0
- sok_ble-0.1.0/sok_ble/__init__.py +0 -0
- sok_ble-0.1.0/sok_ble/const.py +33 -0
- sok_ble-0.1.0/sok_ble/exceptions.py +11 -0
- sok_ble-0.1.0/sok_ble/sok_bluetooth_device.py +160 -0
- sok_ble-0.1.0/sok_ble/sok_parser.py +135 -0
- sok_ble-0.1.0/sok_ble.egg-info/PKG-INFO +40 -0
- sok_ble-0.1.0/sok_ble.egg-info/SOURCES.txt +19 -0
- sok_ble-0.1.0/sok_ble.egg-info/dependency_links.txt +1 -0
- sok_ble-0.1.0/sok_ble.egg-info/requires.txt +2 -0
- sok_ble-0.1.0/sok_ble.egg-info/top_level.txt +1 -0
- sok_ble-0.1.0/tests/test_const.py +13 -0
- sok_ble-0.1.0/tests/test_derived.py +29 -0
- sok_ble-0.1.0/tests/test_device_full.py +59 -0
- sok_ble-0.1.0/tests/test_device_minimal.py +41 -0
- sok_ble-0.1.0/tests/test_exceptions.py +19 -0
- sok_ble-0.1.0/tests/test_parser_full.py +35 -0
- sok_ble-0.1.0/tests/test_parser_info.py +24 -0
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
|
+

|
|
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
|
+
```
|
sok_ble-0.1.0/README.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# SOK BLE
|
|
2
|
+
|
|
3
|
+

|
|
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
|
sok_ble-0.1.0/setup.cfg
ADDED
|
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,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
|
+

|
|
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 @@
|
|
|
1
|
+
|
|
@@ -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)
|