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.
@@ -1,9 +1,10 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: dexter-controller
3
- Version: 0.1.2
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.1.2"
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 .loadcell_device import LoadCellDevice
3
- from .dexter_hand_controller import DexterHandController
4
-
5
- __all__ = [
6
- "Finger",
7
- "FingerData",
8
- "LoadCellDevice",
9
- "DexterHandController",
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()
@@ -2,22 +2,32 @@ import threading
2
2
  import time
3
3
  from collections import defaultdict
4
4
 
5
- from dexter_controller import Finger, FingerData, LoadCellDevice
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__(self, mapping):
10
- if not mapping:
11
- raise ValueError("mapping cannot be empty")
12
-
13
- self._mapping = dict(mapping) # for reconnects
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._finger_to_device = {} # {Finger: (device, 0|1)}
17
- self._device_index_to_finger = {} # {(device, 0|1): Finger}
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
- self.finger_data = defaultdict(lambda: FingerData([0, 0, 0, 0]))
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
- # Initial connect attempt (tolerates missing devices)
32
- self._connect_from_mapping(self._mapping)
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
- for d in self._devices:
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 indices (0 and 1). Ignore extra configured fingers.
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 (indices 0 and 1)."
93
+ "only the first two will be used."
75
94
  )
76
95
 
77
96
  for i, finger in enumerate(fingers[:2]):
78
- self._finger_to_device[finger] = (device, i)
79
- self._device_index_to_finger[(device, i)] = finger
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
- device_index_to_finger = dict(self._device_index_to_finger)
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 or len(data) < 8:
140
+ if not data:
92
141
  continue # ignore malformed/partial events
93
142
 
94
- # 8 values: 4 per index (0 and 1)
95
- for i in (0, 1):
96
- finger = device_index_to_finger.get((device, i))
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[i * 4 : (i + 1) * 4])
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 cb in self._callbacks[finger]:
106
- cb(finger_data)
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._finger_to_device.keys())
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._connect_from_mapping(self._mapping)
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._finger_to_device.clear()
163
- self._device_index_to_finger.clear()
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) != 4:
15
- raise ValueError("raw_data must have exactly 4 elements")
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