dexter-controller 0.1.2__tar.gz → 0.2.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.
- {dexter_controller-0.1.2 → dexter_controller-0.2.0}/PKG-INFO +2 -1
- {dexter_controller-0.1.2 → dexter_controller-0.2.0}/pyproject.toml +2 -1
- {dexter_controller-0.1.2 → dexter_controller-0.2.0}/src/dexter_controller/__init__.py +12 -10
- dexter_controller-0.2.0/src/dexter_controller/ble_loadcell_device.py +137 -0
- {dexter_controller-0.1.2 → dexter_controller-0.2.0}/src/dexter_controller/dexter_hand_controller.py +90 -40
- {dexter_controller-0.1.2 → dexter_controller-0.2.0}/src/dexter_controller/finger.py +16 -16
- {dexter_controller-0.1.2 → dexter_controller-0.2.0}/README.md +0 -0
- {dexter_controller-0.1.2 → dexter_controller-0.2.0}/src/dexter_controller/loadcell_device.py +0 -0
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: dexter-controller
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Dexter Controller for the HARP Load Cells
|
|
5
5
|
Author: Hardware and Software Platform, Champalimaud Foundation
|
|
6
6
|
Author-email: Hardware and Software Platform, Champalimaud Foundation <software@research.fchampalimaud.org>
|
|
7
|
+
Requires-Dist: bleak==2.1.1
|
|
7
8
|
Requires-Dist: harp-loadcells>=0.1.0a3
|
|
8
9
|
Requires-Python: >=3.10
|
|
9
10
|
Project-URL: Bug Tracker, https://github.com/fchampalimaud/dexter-controller/issues
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "dexter-controller"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "Dexter Controller for the HARP Load Cells"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -8,6 +8,7 @@ authors = [
|
|
|
8
8
|
]
|
|
9
9
|
requires-python = ">=3.10"
|
|
10
10
|
dependencies = [
|
|
11
|
+
"bleak==2.1.1",
|
|
11
12
|
"harp-loadcells>=0.1.0a3",
|
|
12
13
|
]
|
|
13
14
|
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
from .finger import Finger, FingerData
|
|
2
|
-
from .
|
|
3
|
-
from .
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
|
|
1
|
+
from .finger import Finger, FingerData
|
|
2
|
+
from .ble_loadcell_device import BLELoadCellDevice
|
|
3
|
+
from .loadcell_device import LoadCellDevice
|
|
4
|
+
from .dexter_hand_controller import DexterHandController
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"Finger",
|
|
8
|
+
"FingerData",
|
|
9
|
+
"LoadCellDevice",
|
|
10
|
+
"BLELoadCellDevice",
|
|
11
|
+
"DexterHandController",
|
|
12
|
+
]
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import struct
|
|
3
|
+
import threading
|
|
4
|
+
from collections import deque
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from bleak import BleakClient, BleakScanner
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
DEVICE_NAME = "Dexter"
|
|
12
|
+
SERVICE_UUID = "a88278d1-7009-4bee-a6f8-e1dc3ff02b92"
|
|
13
|
+
CHAR_UUID = "a88278d2-7009-4bee-a6f8-e1dc3ff02b92"
|
|
14
|
+
|
|
15
|
+
LOAD_CELLS = 15
|
|
16
|
+
NUM_READINGS = 2
|
|
17
|
+
BLOCK_SIZE = 8 + (LOAD_CELLS * 2)
|
|
18
|
+
PACKET_SIZE = 4 + (NUM_READINGS * BLOCK_SIZE)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class BLELoadCellEvent:
|
|
23
|
+
payload: list[int]
|
|
24
|
+
counter: int
|
|
25
|
+
timestamp_us: int
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BLELoadCellDevice:
|
|
29
|
+
def __init__(self):
|
|
30
|
+
self.identifier = f"BLE:{DEVICE_NAME}"
|
|
31
|
+
self._queue = deque()
|
|
32
|
+
self._queue_lock = threading.Lock()
|
|
33
|
+
|
|
34
|
+
self._stop_event = threading.Event()
|
|
35
|
+
self._ready_event = threading.Event()
|
|
36
|
+
|
|
37
|
+
self._client: Optional[BleakClient] = None
|
|
38
|
+
self._connection_error: Optional[Exception] = None
|
|
39
|
+
self._is_connected = False
|
|
40
|
+
|
|
41
|
+
self._thread = threading.Thread(target=self._thread_main, daemon=True)
|
|
42
|
+
self._thread.start()
|
|
43
|
+
self._ready_event.wait(timeout=15.0)
|
|
44
|
+
|
|
45
|
+
if not self._is_connected:
|
|
46
|
+
error = self._connection_error or RuntimeError(
|
|
47
|
+
"No matching BLE peripheral found"
|
|
48
|
+
)
|
|
49
|
+
raise error
|
|
50
|
+
|
|
51
|
+
def _thread_main(self):
|
|
52
|
+
try:
|
|
53
|
+
asyncio.run(self._run())
|
|
54
|
+
except Exception as exc:
|
|
55
|
+
self._connection_error = exc
|
|
56
|
+
self._ready_event.set()
|
|
57
|
+
finally:
|
|
58
|
+
self._is_connected = False
|
|
59
|
+
|
|
60
|
+
async def _discover_target(self):
|
|
61
|
+
target = DEVICE_NAME.lower()
|
|
62
|
+
discovered = await BleakScanner.discover(timeout=10, return_adv=True)
|
|
63
|
+
|
|
64
|
+
for _, (candidate, adv_data) in discovered.items():
|
|
65
|
+
name = (candidate.name or adv_data.local_name or "").lower()
|
|
66
|
+
if target in name:
|
|
67
|
+
self.identifier = (
|
|
68
|
+
f"BLE:{candidate.name or DEVICE_NAME} ({candidate.address})"
|
|
69
|
+
)
|
|
70
|
+
return candidate
|
|
71
|
+
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
def _notification_callback(self, _, data: bytearray):
|
|
75
|
+
if len(data) < PACKET_SIZE:
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
counter = struct.unpack_from("<i", data, 0)[0]
|
|
79
|
+
|
|
80
|
+
with self._queue_lock:
|
|
81
|
+
for reading_index in range(NUM_READINGS):
|
|
82
|
+
base = 4 + (reading_index * BLOCK_SIZE)
|
|
83
|
+
timestamp_us = struct.unpack_from("<Q", data, base)[0]
|
|
84
|
+
load_cells = list(struct.unpack_from(f"<{LOAD_CELLS}H", data, base + 8))
|
|
85
|
+
self._queue.append(
|
|
86
|
+
BLELoadCellEvent(
|
|
87
|
+
payload=load_cells,
|
|
88
|
+
counter=counter,
|
|
89
|
+
timestamp_us=timestamp_us,
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
async def _run(self):
|
|
94
|
+
print("Searching for BLE device...")
|
|
95
|
+
device = await self._discover_target()
|
|
96
|
+
if not device:
|
|
97
|
+
self._connection_error = RuntimeError("No matching BLE peripheral found")
|
|
98
|
+
self._ready_event.set()
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
self._client = BleakClient(
|
|
102
|
+
device,
|
|
103
|
+
disconnected_callback=lambda _: self._stop_event.set(),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
await self._client.connect()
|
|
108
|
+
await self._client.start_notify(CHAR_UUID, self._notification_callback)
|
|
109
|
+
self._is_connected = True
|
|
110
|
+
self._ready_event.set()
|
|
111
|
+
|
|
112
|
+
while not self._stop_event.is_set():
|
|
113
|
+
await asyncio.sleep(0.05)
|
|
114
|
+
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
self._connection_error = exc
|
|
117
|
+
self._ready_event.set()
|
|
118
|
+
|
|
119
|
+
finally:
|
|
120
|
+
self._is_connected = False
|
|
121
|
+
if self._client and self._client.is_connected:
|
|
122
|
+
try:
|
|
123
|
+
await self._client.stop_notify(CHAR_UUID)
|
|
124
|
+
finally:
|
|
125
|
+
await self._client.disconnect()
|
|
126
|
+
|
|
127
|
+
def get_events(self):
|
|
128
|
+
events = []
|
|
129
|
+
with self._queue_lock:
|
|
130
|
+
while self._queue:
|
|
131
|
+
events.append(self._queue.popleft())
|
|
132
|
+
return events
|
|
133
|
+
|
|
134
|
+
def close(self):
|
|
135
|
+
self._stop_event.set()
|
|
136
|
+
if self._thread.is_alive():
|
|
137
|
+
self._thread.join()
|
{dexter_controller-0.1.2 → dexter_controller-0.2.0}/src/dexter_controller/dexter_hand_controller.py
RENAMED
|
@@ -2,22 +2,32 @@ import threading
|
|
|
2
2
|
import time
|
|
3
3
|
from collections import defaultdict
|
|
4
4
|
|
|
5
|
-
from
|
|
5
|
+
from .finger import Finger, FingerData
|
|
6
|
+
from .ble_loadcell_device import BLELoadCellDevice
|
|
7
|
+
from .loadcell_device import LoadCellDevice
|
|
6
8
|
|
|
7
9
|
|
|
8
10
|
class DexterHandController:
|
|
9
|
-
def __init__(
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
mapping: dict | None = None, # {com_port: [Finger, ...]}
|
|
14
|
+
use_ble=False, # use BLE instead of serial
|
|
15
|
+
):
|
|
16
|
+
if not use_ble and not mapping:
|
|
17
|
+
raise ValueError("mapping cannot be empty when use_ble is False")
|
|
18
|
+
|
|
19
|
+
self._mapping = dict(mapping or {})
|
|
20
|
+
self._use_ble = use_ble
|
|
14
21
|
self._lock = threading.Lock()
|
|
15
22
|
|
|
16
|
-
self.
|
|
17
|
-
self.
|
|
23
|
+
self._finger_to_binding = {} # {Finger: (device, start, stop)}
|
|
24
|
+
self._device_to_bindings = defaultdict(
|
|
25
|
+
list
|
|
26
|
+
) # {device: [(Finger, start, stop), ...]}
|
|
18
27
|
self._devices = []
|
|
19
28
|
|
|
20
|
-
|
|
29
|
+
default_width = 3 if use_ble else 4
|
|
30
|
+
self.finger_data = defaultdict(lambda: FingerData([0] * default_width))
|
|
21
31
|
self._finger_has_data = defaultdict(bool) # {Finger: bool}
|
|
22
32
|
self._finger_last_update_ts = defaultdict(float) # {Finger: float}
|
|
23
33
|
|
|
@@ -28,13 +38,32 @@ class DexterHandController:
|
|
|
28
38
|
self.available_ports = []
|
|
29
39
|
self.unavailable_ports = []
|
|
30
40
|
|
|
31
|
-
|
|
32
|
-
|
|
41
|
+
if self._use_ble:
|
|
42
|
+
self._connect_ble()
|
|
43
|
+
else:
|
|
44
|
+
self._connect_from_mapping(self._mapping)
|
|
33
45
|
|
|
34
46
|
# Poll loop works in background thread
|
|
35
47
|
self._poll_thread = threading.Thread(target=self._poll_loop, daemon=True)
|
|
36
48
|
self._poll_thread.start()
|
|
37
49
|
|
|
50
|
+
def _reset_connections(self):
|
|
51
|
+
for device in self._devices:
|
|
52
|
+
try:
|
|
53
|
+
device.close()
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
self._devices.clear()
|
|
58
|
+
self._finger_to_binding.clear()
|
|
59
|
+
self._device_to_bindings.clear()
|
|
60
|
+
self.available_ports.clear()
|
|
61
|
+
self.unavailable_ports.clear()
|
|
62
|
+
|
|
63
|
+
def _bind_finger(self, finger, device, start, stop):
|
|
64
|
+
self._finger_to_binding[finger] = (device, start, stop)
|
|
65
|
+
self._device_to_bindings[device].append((finger, start, stop))
|
|
66
|
+
|
|
38
67
|
def _connect_from_mapping(self, mapping):
|
|
39
68
|
"""
|
|
40
69
|
Disconnect any existing devices and try to connect everything described by mapping.
|
|
@@ -43,17 +72,7 @@ class DexterHandController:
|
|
|
43
72
|
"""
|
|
44
73
|
with self._lock:
|
|
45
74
|
# Tear down existing connections
|
|
46
|
-
|
|
47
|
-
try:
|
|
48
|
-
d.close()
|
|
49
|
-
except Exception:
|
|
50
|
-
pass
|
|
51
|
-
|
|
52
|
-
self._devices.clear()
|
|
53
|
-
self._finger_to_device.clear()
|
|
54
|
-
self._device_index_to_finger.clear()
|
|
55
|
-
self.available_ports.clear()
|
|
56
|
-
self.unavailable_ports.clear()
|
|
75
|
+
self._reset_connections()
|
|
57
76
|
|
|
58
77
|
# Reconnect all configured ports
|
|
59
78
|
for com_port, fingers in mapping.items():
|
|
@@ -67,43 +86,71 @@ class DexterHandController:
|
|
|
67
86
|
self._devices.append(device)
|
|
68
87
|
self.available_ports.append(com_port)
|
|
69
88
|
|
|
70
|
-
# A LoadCellDevice yields two
|
|
89
|
+
# A serial LoadCellDevice yields two 4-value finger slices.
|
|
71
90
|
if len(fingers) > 2:
|
|
72
91
|
print(
|
|
73
92
|
f"Warning: device {com_port} configured with {len(fingers)} fingers; "
|
|
74
|
-
"only the first two will be used
|
|
93
|
+
"only the first two will be used."
|
|
75
94
|
)
|
|
76
95
|
|
|
77
96
|
for i, finger in enumerate(fingers[:2]):
|
|
78
|
-
|
|
79
|
-
|
|
97
|
+
start = i * 4
|
|
98
|
+
stop = start + 4
|
|
99
|
+
self._bind_finger(finger, device, start, stop)
|
|
100
|
+
|
|
101
|
+
def _connect_ble(self):
|
|
102
|
+
with self._lock:
|
|
103
|
+
self._reset_connections()
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
device = BLELoadCellDevice()
|
|
107
|
+
except Exception as e:
|
|
108
|
+
self.unavailable_ports.append("BLE:Dexter")
|
|
109
|
+
print(f"Failed to connect to BLE device: {e}")
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
self._devices.append(device)
|
|
113
|
+
self.available_ports.append(device.identifier)
|
|
114
|
+
|
|
115
|
+
for finger in (
|
|
116
|
+
Finger.THUMB,
|
|
117
|
+
Finger.INDEX,
|
|
118
|
+
Finger.MIDDLE,
|
|
119
|
+
Finger.RING,
|
|
120
|
+
Finger.PINKY,
|
|
121
|
+
):
|
|
122
|
+
# BLE device sends all fingers in one payload, so start/stop indices are fixed
|
|
123
|
+
start = finger.value * 3
|
|
124
|
+
stop = start + 3
|
|
125
|
+
self._bind_finger(finger, device, start, stop)
|
|
80
126
|
|
|
81
127
|
def _poll_loop(self):
|
|
82
128
|
while not self._stop_event.is_set():
|
|
83
129
|
# Snapshot under lock to avoid races with reconnect()/close()
|
|
84
130
|
with self._lock:
|
|
85
131
|
devices = list(self._devices)
|
|
86
|
-
|
|
132
|
+
device_to_bindings = {
|
|
133
|
+
device: list(bindings)
|
|
134
|
+
for device, bindings in self._device_to_bindings.items()
|
|
135
|
+
}
|
|
87
136
|
|
|
88
137
|
for device in devices:
|
|
89
138
|
for event in device.get_events():
|
|
90
139
|
data = getattr(event, "payload", None)
|
|
91
|
-
if not data
|
|
140
|
+
if not data:
|
|
92
141
|
continue # ignore malformed/partial events
|
|
93
142
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if finger is None:
|
|
98
|
-
continue
|
|
143
|
+
for finger, start, stop in device_to_bindings.get(device, ()):
|
|
144
|
+
if len(data) < stop:
|
|
145
|
+
continue # ignore malformed/partial events
|
|
99
146
|
|
|
100
|
-
finger_data = list(data[
|
|
147
|
+
finger_data = list(data[start:stop])
|
|
101
148
|
self.finger_data[finger].raw_data = finger_data
|
|
102
149
|
self._finger_has_data[finger] = True
|
|
103
150
|
self._finger_last_update_ts[finger] = time.time()
|
|
104
151
|
|
|
105
|
-
for
|
|
106
|
-
|
|
152
|
+
for callback in self._callbacks[finger]:
|
|
153
|
+
callback(finger_data)
|
|
107
154
|
|
|
108
155
|
time.sleep(0.001)
|
|
109
156
|
|
|
@@ -126,7 +173,7 @@ class DexterHandController:
|
|
|
126
173
|
- fingers: {Finger: {has_data: bool, last_update_ts: float, mapped: bool}}
|
|
127
174
|
"""
|
|
128
175
|
with self._lock:
|
|
129
|
-
mapped_fingers = set(self.
|
|
176
|
+
mapped_fingers = set(self._finger_to_binding.keys())
|
|
130
177
|
available_ports = list(self.available_ports)
|
|
131
178
|
unavailable_ports = list(self.unavailable_ports)
|
|
132
179
|
|
|
@@ -148,7 +195,10 @@ class DexterHandController:
|
|
|
148
195
|
Disconnect any existing devices and try to reconnect everything from the original mapping.
|
|
149
196
|
Returns: (available_ports, unavailable_ports)
|
|
150
197
|
"""
|
|
151
|
-
self.
|
|
198
|
+
if self._use_ble:
|
|
199
|
+
self._connect_ble()
|
|
200
|
+
else:
|
|
201
|
+
self._connect_from_mapping(self._mapping)
|
|
152
202
|
return list(self.available_ports), list(self.unavailable_ports)
|
|
153
203
|
|
|
154
204
|
def close(self):
|
|
@@ -159,8 +209,8 @@ class DexterHandController:
|
|
|
159
209
|
for device in self._devices:
|
|
160
210
|
device.close()
|
|
161
211
|
self._devices.clear()
|
|
162
|
-
self.
|
|
163
|
-
self.
|
|
212
|
+
self._finger_to_binding.clear()
|
|
213
|
+
self._device_to_bindings.clear()
|
|
164
214
|
|
|
165
215
|
self._callbacks.clear()
|
|
166
216
|
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
from enum import Enum
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
class Finger(Enum):
|
|
5
|
-
THUMB = 0
|
|
6
|
-
INDEX = 1
|
|
7
|
-
MIDDLE = 2
|
|
8
|
-
RING = 3
|
|
9
|
-
PINKY = 4
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class FingerData:
|
|
13
|
-
def __init__(self, raw_data):
|
|
14
|
-
if raw_data is None or len(raw_data)
|
|
15
|
-
raise ValueError("raw_data must have
|
|
16
|
-
self.raw_data = raw_data
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Finger(Enum):
|
|
5
|
+
THUMB = 0
|
|
6
|
+
INDEX = 1
|
|
7
|
+
MIDDLE = 2
|
|
8
|
+
RING = 3
|
|
9
|
+
PINKY = 4
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FingerData:
|
|
13
|
+
def __init__(self, raw_data):
|
|
14
|
+
if raw_data is None or len(raw_data) == 0:
|
|
15
|
+
raise ValueError("raw_data must have at least one element")
|
|
16
|
+
self.raw_data = raw_data
|
|
File without changes
|
{dexter_controller-0.1.2 → dexter_controller-0.2.0}/src/dexter_controller/loadcell_device.py
RENAMED
|
File without changes
|