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 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,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))
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
+ ![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,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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ sok_ble