sok-ble 0.1.0__tar.gz → 0.1.1__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 → sok_ble-0.1.1}/PKG-INFO +1 -1
- {sok_ble-0.1.0 → sok_ble-0.1.1}/pyproject.toml +1 -1
- {sok_ble-0.1.0 → sok_ble-0.1.1}/sok_ble/sok_bluetooth_device.py +37 -12
- {sok_ble-0.1.0 → sok_ble-0.1.1}/sok_ble/sok_parser.py +25 -33
- {sok_ble-0.1.0 → sok_ble-0.1.1}/sok_ble.egg-info/PKG-INFO +1 -1
- {sok_ble-0.1.0 → sok_ble-0.1.1}/sok_ble.egg-info/SOURCES.txt +1 -0
- {sok_ble-0.1.0 → sok_ble-0.1.1}/tests/test_device_full.py +15 -17
- {sok_ble-0.1.0 → sok_ble-0.1.1}/tests/test_device_minimal.py +8 -6
- sok_ble-0.1.1/tests/test_integration_mock.py +68 -0
- {sok_ble-0.1.0 → sok_ble-0.1.1}/tests/test_parser_full.py +8 -7
- {sok_ble-0.1.0 → sok_ble-0.1.1}/tests/test_parser_info.py +2 -2
- {sok_ble-0.1.0 → sok_ble-0.1.1}/README.md +0 -0
- {sok_ble-0.1.0 → sok_ble-0.1.1}/setup.cfg +0 -0
- {sok_ble-0.1.0 → sok_ble-0.1.1}/sok_ble/__init__.py +0 -0
- {sok_ble-0.1.0 → sok_ble-0.1.1}/sok_ble/const.py +0 -0
- {sok_ble-0.1.0 → sok_ble-0.1.1}/sok_ble/exceptions.py +0 -0
- {sok_ble-0.1.0 → sok_ble-0.1.1}/sok_ble.egg-info/dependency_links.txt +0 -0
- {sok_ble-0.1.0 → sok_ble-0.1.1}/sok_ble.egg-info/requires.txt +0 -0
- {sok_ble-0.1.0 → sok_ble-0.1.1}/sok_ble.egg-info/top_level.txt +0 -0
- {sok_ble-0.1.0 → sok_ble-0.1.1}/tests/test_const.py +0 -0
- {sok_ble-0.1.0 → sok_ble-0.1.1}/tests/test_derived.py +0 -0
- {sok_ble-0.1.0 → sok_ble-0.1.1}/tests/test_exceptions.py +0 -0
|
@@ -4,6 +4,8 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from contextlib import asynccontextmanager
|
|
6
6
|
from typing import AsyncIterator, Optional
|
|
7
|
+
import asyncio
|
|
8
|
+
import struct
|
|
7
9
|
import logging
|
|
8
10
|
import statistics
|
|
9
11
|
|
|
@@ -65,32 +67,55 @@ class SokBluetoothDevice:
|
|
|
65
67
|
await client.disconnect()
|
|
66
68
|
logger.debug("Disconnected from %s", self._ble_device.address)
|
|
67
69
|
|
|
70
|
+
async def _send_command(
|
|
71
|
+
self, client: BleakClientWithServiceCache, cmd: int, expected: int
|
|
72
|
+
) -> bytes:
|
|
73
|
+
"""Send a command and return the response bytes with the given header."""
|
|
74
|
+
|
|
75
|
+
# If the client supports notifications (real BLE client), prefer that
|
|
76
|
+
start_notify = getattr(client, "start_notify", None)
|
|
77
|
+
if start_notify is None:
|
|
78
|
+
await client.write_gatt_char(UUID_TX, _sok_command(cmd))
|
|
79
|
+
data = bytes(await client.read_gatt_char(UUID_RX))
|
|
80
|
+
return data
|
|
81
|
+
|
|
82
|
+
queue: asyncio.Queue[bytes] = asyncio.Queue()
|
|
83
|
+
|
|
84
|
+
def handler(_: int, data: bytearray) -> None:
|
|
85
|
+
queue.put_nowait(bytes(data))
|
|
86
|
+
|
|
87
|
+
await client.start_notify(UUID_RX, handler)
|
|
88
|
+
try:
|
|
89
|
+
await client.write_gatt_char(UUID_TX, _sok_command(cmd))
|
|
90
|
+
while True:
|
|
91
|
+
data = await asyncio.wait_for(queue.get(), 5.0)
|
|
92
|
+
if struct.unpack_from(">H", data)[0] == expected:
|
|
93
|
+
return data
|
|
94
|
+
finally:
|
|
95
|
+
await client.stop_notify(UUID_RX)
|
|
96
|
+
|
|
68
97
|
async def async_update(self) -> None:
|
|
69
98
|
"""Poll the device for all telemetry and update attributes."""
|
|
70
99
|
responses: dict[int, bytes] = {}
|
|
71
100
|
async with self._connect() as client:
|
|
72
101
|
logger.debug("Send C1")
|
|
73
|
-
await
|
|
74
|
-
|
|
75
|
-
logger.debug("Recv 0xCCF0: %s", data.hex())
|
|
102
|
+
data = await self._send_command(client, 0xC1, 0xCCF0)
|
|
103
|
+
logger.debug("Recv 0x%04X: %s", struct.unpack_from(">H", data)[0], data.hex())
|
|
76
104
|
responses[0xCCF0] = data
|
|
77
105
|
|
|
78
106
|
logger.debug("Send C1")
|
|
79
|
-
await
|
|
80
|
-
|
|
81
|
-
logger.debug("Recv 0xCCF2: %s", data.hex())
|
|
107
|
+
data = await self._send_command(client, 0xC1, 0xCCF2)
|
|
108
|
+
logger.debug("Recv 0x%04X: %s", struct.unpack_from(">H", data)[0], data.hex())
|
|
82
109
|
responses[0xCCF2] = data
|
|
83
110
|
|
|
84
111
|
logger.debug("Send C2")
|
|
85
|
-
await
|
|
86
|
-
|
|
87
|
-
logger.debug("Recv 0xCCF3: %s", data.hex())
|
|
112
|
+
data = await self._send_command(client, 0xC2, 0xCCF3)
|
|
113
|
+
logger.debug("Recv 0x%04X: %s", struct.unpack_from(">H", data)[0], data.hex())
|
|
88
114
|
responses[0xCCF3] = data
|
|
89
115
|
|
|
90
116
|
logger.debug("Send C2")
|
|
91
|
-
await
|
|
92
|
-
|
|
93
|
-
logger.debug("Recv 0xCCF4: %s", data.hex())
|
|
117
|
+
data = await self._send_command(client, 0xC2, 0xCCF4)
|
|
118
|
+
logger.debug("Recv 0x%04X: %s", struct.unpack_from(">H", data)[0], data.hex())
|
|
94
119
|
responses[0xCCF4] = data
|
|
95
120
|
|
|
96
121
|
parsed = SokParser.parse_all(responses)
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
6
|
import struct
|
|
7
|
+
import statistics
|
|
7
8
|
from typing import Dict, Sequence
|
|
8
9
|
|
|
9
10
|
from sok_ble.exceptions import InvalidResponseError
|
|
@@ -43,27 +44,19 @@ class SokParser:
|
|
|
43
44
|
|
|
44
45
|
@staticmethod
|
|
45
46
|
def parse_info(buf: bytes) -> Dict[str, float | int]:
|
|
46
|
-
"""Parse the information frame for
|
|
47
|
+
"""Parse the information frame for current, SOC and cycles."""
|
|
47
48
|
logger.debug("parse_info input: %s", buf.hex())
|
|
48
|
-
if len(buf) <
|
|
49
|
+
if len(buf) < 20:
|
|
49
50
|
raise InvalidResponseError("Info buffer too short")
|
|
50
51
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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]
|
|
52
|
+
current = get_le_int3(buf, 5) / 1000
|
|
53
|
+
num_cycles = get_le_ushort(buf, 14)
|
|
54
|
+
soc = get_le_ushort(buf, 16)
|
|
62
55
|
|
|
63
56
|
result = {
|
|
64
|
-
"voltage": voltage,
|
|
65
57
|
"current": current,
|
|
66
58
|
"soc": soc,
|
|
59
|
+
"num_cycles": num_cycles,
|
|
67
60
|
}
|
|
68
61
|
logger.debug("parse_info result: %s", result)
|
|
69
62
|
return result
|
|
@@ -72,27 +65,23 @@ class SokParser:
|
|
|
72
65
|
def parse_temps(buf: bytes) -> float:
|
|
73
66
|
"""Parse the temperature from the temperature frame."""
|
|
74
67
|
logger.debug("parse_temps input: %s", buf.hex())
|
|
75
|
-
if len(buf) <
|
|
68
|
+
if len(buf) < 20:
|
|
76
69
|
raise InvalidResponseError("Temp buffer too short")
|
|
77
70
|
|
|
78
|
-
temperature = get_le_short(buf, 5)
|
|
71
|
+
temperature = get_le_short(buf, 5)
|
|
79
72
|
logger.debug("parse_temps result: %s", temperature)
|
|
80
73
|
return temperature
|
|
81
74
|
|
|
82
75
|
@staticmethod
|
|
83
76
|
def parse_capacity_cycles(buf: bytes) -> Dict[str, float | int]:
|
|
84
|
-
"""Parse rated capacity
|
|
77
|
+
"""Parse rated capacity."""
|
|
85
78
|
logger.debug("parse_capacity_cycles input: %s", buf.hex())
|
|
86
|
-
if len(buf) <
|
|
79
|
+
if len(buf) < 20:
|
|
87
80
|
raise InvalidResponseError("Capacity buffer too short")
|
|
88
81
|
|
|
89
|
-
capacity =
|
|
90
|
-
num_cycles = get_le_ushort(buf, 4)
|
|
82
|
+
capacity = get_be_uint3(buf, 5) / 128
|
|
91
83
|
|
|
92
|
-
result = {
|
|
93
|
-
"capacity": capacity,
|
|
94
|
-
"num_cycles": num_cycles,
|
|
95
|
-
}
|
|
84
|
+
result = {"capacity": capacity}
|
|
96
85
|
logger.debug("parse_capacity_cycles result: %s", result)
|
|
97
86
|
return result
|
|
98
87
|
|
|
@@ -100,15 +89,13 @@ class SokParser:
|
|
|
100
89
|
def parse_cells(buf: bytes) -> list[float]:
|
|
101
90
|
"""Parse individual cell voltages."""
|
|
102
91
|
logger.debug("parse_cells input: %s", buf.hex())
|
|
103
|
-
if len(buf) <
|
|
92
|
+
if len(buf) < 20:
|
|
104
93
|
raise InvalidResponseError("Cells buffer too short")
|
|
105
94
|
|
|
106
|
-
cells = [
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
get_le_ushort(buf, 4) / 1000
|
|
110
|
-
get_le_ushort(buf, 6) / 1000,
|
|
111
|
-
]
|
|
95
|
+
cells = [0.0, 0.0, 0.0, 0.0]
|
|
96
|
+
for x in range(4):
|
|
97
|
+
cell_idx = buf[2 + x * 4]
|
|
98
|
+
cells[cell_idx - 1] = get_le_ushort(buf, 3 + x * 4) / 1000
|
|
112
99
|
logger.debug("parse_cells result: %s", cells)
|
|
113
100
|
return cells
|
|
114
101
|
|
|
@@ -125,10 +112,15 @@ class SokParser:
|
|
|
125
112
|
capacity_info = cls.parse_capacity_cycles(responses[0xCCF3])
|
|
126
113
|
cells = cls.parse_cells(responses[0xCCF4])
|
|
127
114
|
|
|
115
|
+
voltage = statistics.mean(cells) * 4
|
|
116
|
+
|
|
128
117
|
result = {
|
|
129
|
-
|
|
118
|
+
"voltage": voltage,
|
|
119
|
+
"current": info["current"],
|
|
120
|
+
"soc": info["soc"],
|
|
130
121
|
"temperature": temperature,
|
|
131
|
-
|
|
122
|
+
"capacity": capacity_info["capacity"],
|
|
123
|
+
"num_cycles": info["num_cycles"],
|
|
132
124
|
"cell_voltages": cells,
|
|
133
125
|
}
|
|
134
126
|
logger.debug("parse_all result: %s", result)
|
|
@@ -24,12 +24,10 @@ class DummyClient:
|
|
|
24
24
|
@pytest.mark.asyncio
|
|
25
25
|
async def test_full_update(monkeypatch):
|
|
26
26
|
responses = [
|
|
27
|
-
bytes.fromhex(
|
|
28
|
-
|
|
29
|
-
),
|
|
30
|
-
bytes.fromhex("
|
|
31
|
-
bytes.fromhex("10 27 00 00 32 00 00 00"),
|
|
32
|
-
bytes.fromhex("E4 0C E9 0C EE 0C F3 0C"),
|
|
27
|
+
bytes.fromhex("ccf0000000102700000000000000320041000000"),
|
|
28
|
+
bytes.fromhex("ccf2000000140000000000000000000000000000"),
|
|
29
|
+
bytes.fromhex("ccf3000000003200000000000000000000000000"),
|
|
30
|
+
bytes.fromhex("ccf401c50c0002c60c0003bf0c0004c00c000000"),
|
|
33
31
|
]
|
|
34
32
|
|
|
35
33
|
dummy = DummyClient(responses)
|
|
@@ -42,18 +40,18 @@ async def test_full_update(monkeypatch):
|
|
|
42
40
|
|
|
43
41
|
await dev.async_update()
|
|
44
42
|
|
|
45
|
-
assert dev.voltage == 13.
|
|
43
|
+
assert dev.voltage == pytest.approx(13.066)
|
|
46
44
|
assert dev.current == 10.0
|
|
47
45
|
assert dev.soc == 65
|
|
48
|
-
assert dev.temperature ==
|
|
46
|
+
assert dev.temperature == 20.0
|
|
49
47
|
assert dev.capacity == 100.0
|
|
50
48
|
assert dev.num_cycles == 50
|
|
51
|
-
assert dev.cell_voltages == [3.
|
|
52
|
-
assert dev.power == pytest.approx(
|
|
53
|
-
assert dev.cell_voltage_max == 3.
|
|
54
|
-
assert dev.cell_voltage_min == 3.
|
|
55
|
-
assert dev.cell_voltage_avg == pytest.approx(3.
|
|
56
|
-
assert dev.cell_voltage_median == pytest.approx(3.
|
|
57
|
-
assert dev.cell_voltage_delta == pytest.approx(0.
|
|
58
|
-
assert dev.cell_index_max ==
|
|
59
|
-
assert dev.cell_index_min ==
|
|
49
|
+
assert dev.cell_voltages == [3.269, 3.27, 3.263, 3.264]
|
|
50
|
+
assert dev.power == pytest.approx(130.66)
|
|
51
|
+
assert dev.cell_voltage_max == 3.27
|
|
52
|
+
assert dev.cell_voltage_min == 3.263
|
|
53
|
+
assert dev.cell_voltage_avg == pytest.approx(3.2665)
|
|
54
|
+
assert dev.cell_voltage_median == pytest.approx(3.2665)
|
|
55
|
+
assert dev.cell_voltage_delta == pytest.approx(0.007)
|
|
56
|
+
assert dev.cell_index_max == 1
|
|
57
|
+
assert dev.cell_index_min == 2
|
|
@@ -7,7 +7,12 @@ from sok_ble import sok_bluetooth_device as device_mod
|
|
|
7
7
|
|
|
8
8
|
class DummyClient:
|
|
9
9
|
def __init__(self, *args, **kwargs):
|
|
10
|
-
|
|
10
|
+
self._responses = [
|
|
11
|
+
bytes.fromhex("ccf0000000102700000000000000320041000000"),
|
|
12
|
+
bytes.fromhex("ccf2000000140000000000000000000000000000"),
|
|
13
|
+
bytes.fromhex("ccf3000000003200000000000000000000000000"),
|
|
14
|
+
bytes.fromhex("ccf401c50c0002c60c0003bf0c0004c00c000000"),
|
|
15
|
+
]
|
|
11
16
|
|
|
12
17
|
async def connect(self):
|
|
13
18
|
return True
|
|
@@ -19,10 +24,7 @@ class DummyClient:
|
|
|
19
24
|
return True
|
|
20
25
|
|
|
21
26
|
async def read_gatt_char(self, *args, **kwargs):
|
|
22
|
-
|
|
23
|
-
return bytes.fromhex(
|
|
24
|
-
"E4 0C E9 0C EE 0C F3 0C 64 00 00 00 00 00 00 00 41 00"
|
|
25
|
-
)
|
|
27
|
+
return self._responses.pop(0)
|
|
26
28
|
|
|
27
29
|
|
|
28
30
|
@pytest.mark.asyncio
|
|
@@ -36,6 +38,6 @@ async def test_minimal_update(monkeypatch):
|
|
|
36
38
|
|
|
37
39
|
await dev.async_update()
|
|
38
40
|
|
|
39
|
-
assert dev.voltage == 13.
|
|
41
|
+
assert dev.voltage == pytest.approx(13.066)
|
|
40
42
|
assert dev.current == 10.0
|
|
41
43
|
assert dev.soc == 65
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from bleak.backends.device import BLEDevice
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
|
|
5
|
+
from sok_ble import sok_bluetooth_device as device_mod
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DummyClient:
|
|
9
|
+
def __init__(self, responses):
|
|
10
|
+
self._responses = list(responses)
|
|
11
|
+
self.writes = []
|
|
12
|
+
|
|
13
|
+
async def connect(self):
|
|
14
|
+
return True
|
|
15
|
+
|
|
16
|
+
async def disconnect(self):
|
|
17
|
+
return True
|
|
18
|
+
|
|
19
|
+
async def write_gatt_char(self, uuid, data):
|
|
20
|
+
self.writes.append((uuid, bytes(data)))
|
|
21
|
+
return True
|
|
22
|
+
|
|
23
|
+
async def read_gatt_char(self, uuid):
|
|
24
|
+
return self._responses.pop(0)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.mark.asyncio
|
|
28
|
+
async def test_async_update_full_flow(monkeypatch):
|
|
29
|
+
responses = [
|
|
30
|
+
bytes.fromhex("ccf0000000102700000000000000320041000000"),
|
|
31
|
+
bytes.fromhex("ccf2000000140000000000000000000000000000"),
|
|
32
|
+
bytes.fromhex("ccf3000000003200000000000000000000000000"),
|
|
33
|
+
bytes.fromhex("ccf401c50c0002c60c0003bf0c0004c00c000000"),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
@asynccontextmanager
|
|
37
|
+
async def fake_connect(self):
|
|
38
|
+
dummy = DummyClient(responses)
|
|
39
|
+
await dummy.connect()
|
|
40
|
+
try:
|
|
41
|
+
yield dummy
|
|
42
|
+
finally:
|
|
43
|
+
await dummy.disconnect()
|
|
44
|
+
|
|
45
|
+
monkeypatch.setattr(device_mod.SokBluetoothDevice, "_connect", fake_connect)
|
|
46
|
+
|
|
47
|
+
dev = device_mod.SokBluetoothDevice(
|
|
48
|
+
BLEDevice("00:11:22:33:44:55", "Test", None, -60)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
await dev.async_update()
|
|
52
|
+
|
|
53
|
+
assert dev.voltage == pytest.approx(13.066)
|
|
54
|
+
assert dev.current == 10.0
|
|
55
|
+
assert dev.soc == 65
|
|
56
|
+
assert dev.temperature == 20.0
|
|
57
|
+
assert dev.capacity == 100.0
|
|
58
|
+
assert dev.num_cycles == 50
|
|
59
|
+
assert dev.cell_voltages == [3.269, 3.27, 3.263, 3.264]
|
|
60
|
+
assert dev.power == pytest.approx(130.66)
|
|
61
|
+
assert dev.cell_voltage_max == 3.27
|
|
62
|
+
assert dev.cell_voltage_min == 3.263
|
|
63
|
+
assert dev.cell_voltage_avg == pytest.approx(3.2665)
|
|
64
|
+
assert dev.cell_voltage_median == pytest.approx(3.2665)
|
|
65
|
+
assert dev.cell_voltage_delta == pytest.approx(0.007)
|
|
66
|
+
assert dev.cell_index_max == 1
|
|
67
|
+
assert dev.cell_index_min == 2
|
|
68
|
+
assert dev.num_samples == 1
|
|
@@ -1,18 +1,19 @@
|
|
|
1
|
+
import pytest
|
|
1
2
|
from sok_ble.sok_parser import SokParser
|
|
2
3
|
|
|
3
4
|
|
|
4
5
|
def test_parse_all():
|
|
5
6
|
info_buf = bytes.fromhex(
|
|
6
|
-
"
|
|
7
|
+
"ccf0000000102700000000000000320041000000"
|
|
7
8
|
)
|
|
8
9
|
temp_buf = bytes.fromhex(
|
|
9
|
-
"
|
|
10
|
+
"ccf2000000140000000000000000000000000000"
|
|
10
11
|
)
|
|
11
12
|
cap_buf = bytes.fromhex(
|
|
12
|
-
"
|
|
13
|
+
"ccf3000000003200000000000000000000000000"
|
|
13
14
|
)
|
|
14
15
|
cell_buf = bytes.fromhex(
|
|
15
|
-
"
|
|
16
|
+
"ccf401c50c0002c60c0003bf0c0004c00c000000"
|
|
16
17
|
)
|
|
17
18
|
|
|
18
19
|
responses = {
|
|
@@ -24,12 +25,12 @@ def test_parse_all():
|
|
|
24
25
|
|
|
25
26
|
result = SokParser.parse_all(responses)
|
|
26
27
|
assert result == {
|
|
27
|
-
"voltage": 13.
|
|
28
|
+
"voltage": pytest.approx(13.066, rel=1e-3),
|
|
28
29
|
"current": 10.0,
|
|
29
30
|
"soc": 65,
|
|
30
|
-
"temperature":
|
|
31
|
+
"temperature": 20.0,
|
|
31
32
|
"capacity": 100.0,
|
|
32
33
|
"num_cycles": 50,
|
|
33
|
-
"cell_voltages": [3.
|
|
34
|
+
"cell_voltages": [3.269, 3.27, 3.263, 3.264],
|
|
34
35
|
}
|
|
35
36
|
|
|
@@ -3,13 +3,13 @@ from sok_ble.sok_parser import SokParser
|
|
|
3
3
|
|
|
4
4
|
def test_parse_info_basic():
|
|
5
5
|
hex_data = bytes.fromhex(
|
|
6
|
-
"
|
|
6
|
+
"ccf0000000102700000000000000320041000000"
|
|
7
7
|
)
|
|
8
8
|
result = SokParser.parse_info(hex_data)
|
|
9
9
|
assert result == {
|
|
10
|
-
"voltage": 13.23,
|
|
11
10
|
"current": 10.0,
|
|
12
11
|
"soc": 65,
|
|
12
|
+
"num_cycles": 50,
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|