sok-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.
- sok_ble/__init__.py +0 -0
- sok_ble/const.py +33 -0
- sok_ble/exceptions.py +11 -0
- sok_ble/sok_bluetooth_device.py +160 -0
- sok_ble/sok_parser.py +135 -0
- sok_ble-0.1.0.dist-info/METADATA +40 -0
- sok_ble-0.1.0.dist-info/RECORD +9 -0
- sok_ble-0.1.0.dist-info/WHEEL +5 -0
- sok_ble-0.1.0.dist-info/top_level.txt +1 -0
sok_ble/__init__.py
ADDED
|
File without changes
|
sok_ble/const.py
ADDED
|
@@ -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])
|
sok_ble/exceptions.py
ADDED
|
@@ -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))
|
sok_ble/sok_parser.py
ADDED
|
@@ -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,9 @@
|
|
|
1
|
+
sok_ble/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
sok_ble/const.py,sha256=mHtJTbWz_dG3v1lhZrLDzMf-QAG9v5QWlHU9ZKsyYdg,998
|
|
3
|
+
sok_ble/exceptions.py,sha256=7H1yUqqnrKyi3LwxRCMF7RkuGp_rgdy9j35nrMOgz44,247
|
|
4
|
+
sok_ble/sok_bluetooth_device.py,sha256=-dMuHzM0FtBh_fu2MURiriOVhOGaLDM2ZQIRk6zDC84,5384
|
|
5
|
+
sok_ble/sok_parser.py,sha256=zyrZwjOc4yeFfQWo_1B3VFxEGXLJBX7UQPL9e5ag1jQ,4441
|
|
6
|
+
sok_ble-0.1.0.dist-info/METADATA,sha256=u1fvysxad3Cg-N2ipnnuhB2V4N8iVQEzel4-INBRpnU,1233
|
|
7
|
+
sok_ble-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
8
|
+
sok_ble-0.1.0.dist-info/top_level.txt,sha256=WTVtlZ2sADYAxKEBkDJPUn4hFAH9NfIZ9Q2HP2RKpbw,8
|
|
9
|
+
sok_ble-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sok_ble
|