pyrobotiqtactile 0.1.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.
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Robotiq
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyrobotiqtactile
3
+ Version: 0.1.0
4
+ Summary: Python support for the Robotiq TSF-85 tactile sensor
5
+ Author: Benoit CASTETS
6
+ License-Expression: BSD-3-Clause
7
+ Project-URL: Homepage, https://github.com/bcastets-robotiq/pyRobotiqTactile
8
+ Project-URL: Repository, https://github.com/bcastets-robotiq/pyRobotiqTactile
9
+ Project-URL: Issues, https://github.com/bcastets-robotiq/pyRobotiqTactile/issues
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Scientific/Engineering
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENCE
20
+ Requires-Dist: numpy>=1.26
21
+ Requires-Dist: pyserial>=3.5
22
+ Dynamic: license-file
23
+
24
+ # pyRobotiqTactile
25
+
26
+ A Python package for communicating with the Robotiq TSF-85 tactile sensor.
27
+
28
+ The project focuses on a public `RobotiqTactileSensor` class that provides:
29
+
30
+ - serial communication setup
31
+ - sensor configuration and autosend control
32
+ - pressure-frame decoding as NumPy arrays
33
+ - accelerometer and gyroscope access
34
+ - a structured `SensorReading` container for the decoded data
35
+
36
+ ## Quick start
37
+
38
+ ```python
39
+ from pyrobotiqtactile import RobotiqTactileSensor
40
+
41
+ sensor = RobotiqTactileSensor()
42
+ sensor.connect("COM3")
43
+ sensor.configure(update_period_ms=50, synchronous=True)
44
+
45
+ reading = sensor.read_frame()
46
+ print(reading.pressure.shape)
47
+ print(sensor.get_imu())
48
+ ```
49
+
50
+ ## Development
51
+
52
+ ```bash
53
+ pip install -e .
54
+ python -m unittest discover -s tests -v
55
+ ```
@@ -0,0 +1,32 @@
1
+ # pyRobotiqTactile
2
+
3
+ A Python package for communicating with the Robotiq TSF-85 tactile sensor.
4
+
5
+ The project focuses on a public `RobotiqTactileSensor` class that provides:
6
+
7
+ - serial communication setup
8
+ - sensor configuration and autosend control
9
+ - pressure-frame decoding as NumPy arrays
10
+ - accelerometer and gyroscope access
11
+ - a structured `SensorReading` container for the decoded data
12
+
13
+ ## Quick start
14
+
15
+ ```python
16
+ from pyrobotiqtactile import RobotiqTactileSensor
17
+
18
+ sensor = RobotiqTactileSensor()
19
+ sensor.connect("COM3")
20
+ sensor.configure(update_period_ms=50, synchronous=True)
21
+
22
+ reading = sensor.read_frame()
23
+ print(reading.pressure.shape)
24
+ print(sensor.get_imu())
25
+ ```
26
+
27
+ ## Development
28
+
29
+ ```bash
30
+ pip install -e .
31
+ python -m unittest discover -s tests -v
32
+ ```
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pyrobotiqtactile"
7
+ version = "0.1.0"
8
+ description = "Python support for the Robotiq TSF-85 tactile sensor"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "numpy>=1.26",
13
+ "pyserial>=3.5",
14
+ ]
15
+ authors = [
16
+ { name = "Benoit CASTETS" },
17
+ ]
18
+ license = "BSD-3-Clause"
19
+ classifiers = [
20
+ "Development Status :: 3 - Alpha",
21
+ "Intended Audience :: Developers",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Topic :: Scientific/Engineering",
27
+ ]
28
+ [project.urls]
29
+ Homepage = "https://github.com/bcastets-robotiq/pyRobotiqTactile"
30
+ Repository = "https://github.com/bcastets-robotiq/pyRobotiqTactile"
31
+ Issues = "https://github.com/bcastets-robotiq/pyRobotiqTactile/issues"
32
+
33
+ [tool.setuptools]
34
+ package-dir = {"" = "src"}
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
38
+
39
+ [tool.uv]
40
+ package = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,35 @@
1
+ """Python package for the Robotiq TSF-85 tactile sensor."""
2
+
3
+ from .data import SensorReading
4
+ from .exceptions import (
5
+ RobotiqTactileError,
6
+ SensorConnectionError,
7
+ SensorNotFoundError,
8
+ SensorTimeoutError,
9
+ )
10
+ from .protocol import (
11
+ BAUD_RATE,
12
+ COMMAND_AUTOSEND_ASYNC,
13
+ COMMAND_AUTOSEND_SYNC,
14
+ COMMAND_ENTER_BOOTLOADER,
15
+ COMMAND_GET_VERSION,
16
+ COMMAND_READ_SENSORS,
17
+ NUM_FINGERS,
18
+ )
19
+ from .sensor import RobotiqTactileSensor
20
+
21
+ __all__ = [
22
+ "BAUD_RATE",
23
+ "COMMAND_AUTOSEND_ASYNC",
24
+ "COMMAND_AUTOSEND_SYNC",
25
+ "COMMAND_ENTER_BOOTLOADER",
26
+ "COMMAND_GET_VERSION",
27
+ "COMMAND_READ_SENSORS",
28
+ "NUM_FINGERS",
29
+ "RobotiqTactileError",
30
+ "RobotiqTactileSensor",
31
+ "SensorConnectionError",
32
+ "SensorNotFoundError",
33
+ "SensorReading",
34
+ "SensorTimeoutError",
35
+ ]
@@ -0,0 +1,54 @@
1
+ """numpy-friendly snapshot of a decoded sensor frame."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import List, Sequence
7
+
8
+ import numpy as np
9
+
10
+ from .protocol import FingerFrame, STATIC_TACTILE_COLS, STATIC_TACTILE_ROWS
11
+
12
+
13
+ @dataclass
14
+ class SensorReading:
15
+ """A snapshot of all fingers' sensor values, as numpy arrays.
16
+
17
+ Array shapes are (num_fingers, ...): index by finger id first, e.g.
18
+ ``reading.pressure[0]`` is finger 0's 7x4 pressure grid.
19
+ """
20
+
21
+ pressure: np.ndarray # (F, 7, 4) uint16 — static tactile grid
22
+ dynamic_tactile: np.ndarray # (F,) int16
23
+ accelerometer: np.ndarray # (F, 3) int16
24
+ gyroscope: np.ndarray # (F, 3) int16
25
+ magnetometer: np.ndarray # (F, 3) int16
26
+ temperature: np.ndarray # (F,) int16
27
+ timestamp: np.ndarray # (F,) uint64 — sensor-side timestamp per finger
28
+
29
+ @classmethod
30
+ def from_fingers(cls, fingers: Sequence[FingerFrame]) -> "SensorReading":
31
+ num_fingers = len(fingers)
32
+ pressure = np.array(
33
+ [f.static_tactile for f in fingers], dtype=np.uint16
34
+ ).reshape(num_fingers, STATIC_TACTILE_ROWS, STATIC_TACTILE_COLS)
35
+ return cls(
36
+ pressure=pressure,
37
+ dynamic_tactile=np.array([f.dynamic_tactile for f in fingers], dtype=np.int16),
38
+ accelerometer=np.array([f.accelerometer for f in fingers], dtype=np.int16),
39
+ gyroscope=np.array([f.gyroscope for f in fingers], dtype=np.int16),
40
+ magnetometer=np.array([f.magnetometer for f in fingers], dtype=np.int16),
41
+ temperature=np.array([f.temperature for f in fingers], dtype=np.int16),
42
+ timestamp=np.array([f.timestamp for f in fingers], dtype=np.uint64),
43
+ )
44
+
45
+ def copy(self) -> "SensorReading":
46
+ return SensorReading(
47
+ pressure=self.pressure.copy(),
48
+ dynamic_tactile=self.dynamic_tactile.copy(),
49
+ accelerometer=self.accelerometer.copy(),
50
+ gyroscope=self.gyroscope.copy(),
51
+ magnetometer=self.magnetometer.copy(),
52
+ temperature=self.temperature.copy(),
53
+ timestamp=self.timestamp.copy(),
54
+ )
@@ -0,0 +1,14 @@
1
+ class RobotiqTactileError(Exception):
2
+ """Base exception for all pyRobotiqTactile errors."""
3
+
4
+
5
+ class SensorNotFoundError(RobotiqTactileError):
6
+ """Raised when no TSF-85 device could be located on any serial port."""
7
+
8
+
9
+ class SensorConnectionError(RobotiqTactileError):
10
+ """Raised when the serial connection to the sensor fails or drops."""
11
+
12
+
13
+ class SensorTimeoutError(RobotiqTactileError):
14
+ """Raised when a request to the sensor (e.g. firmware version) times out."""
@@ -0,0 +1,224 @@
1
+ """
2
+ Low-level USB packet framing and parsing for the Robotiq TSF-85 tactile sensor.
3
+
4
+ Wire format reverse-ported from Robotiq/tactile_sensors (communicator.cpp /
5
+ sensor_quickstart/protocol.py): each packet is a 4-byte header (start byte,
6
+ CRC8 placeholder, command, data length) followed by a sequence of
7
+ [type+finger byte, big-endian payload] fields.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from typing import List, Tuple
14
+
15
+ # Serial link parameters (fixed by the sensor's hardware/firmware)
16
+ BAUD_RATE = 115200
17
+ DATA_BITS = 8
18
+ PARITY = "N"
19
+ STOP_BITS = 1
20
+
21
+ # USB VID:PID pairs used to auto-discover the sensor's serial port
22
+ MASTER_HUB_VID_PID_PAIRS: Tuple[Tuple[int, int], ...] = (
23
+ (0x16D0, 0x14CC), # Robotiq master hub
24
+ (0x04B4, 0xF232), # Cypress USB UART (older units)
25
+ )
26
+
27
+ # Packet framing
28
+ START_BYTE = 0x9A
29
+ HEADER_SIZE = 4
30
+ MAX_DATA_SIZE = 256
31
+
32
+ # Commands
33
+ COMMAND_READ_SENSORS = 0x61
34
+ COMMAND_AUTOSEND_SYNC = 0x58
35
+ COMMAND_AUTOSEND_ASYNC = 0x59
36
+ COMMAND_ENTER_BOOTLOADER = 0xE2
37
+ COMMAND_GET_VERSION = 0xE3
38
+
39
+ # Sensor type identifiers (high nibble of the per-field type byte)
40
+ SENSOR_STATIC_TACTILE = 0x10
41
+ SENSOR_DYNAMIC_TACTILE = 0x20
42
+ SENSOR_ACCELEROMETER = 0x30
43
+ SENSOR_GYROSCOPE = 0x40
44
+ SENSOR_MAGNETOMETER = 0x50
45
+ SENSOR_TEMPERATURE = 0x60
46
+ SENSOR_TIMESTAMP = 0x70
47
+
48
+ # Fixed field sizes (element counts, not bytes)
49
+ STATIC_TACTILE_ROWS = 7
50
+ STATIC_TACTILE_COLS = 4
51
+ STATIC_TACTILE_SIZE = STATIC_TACTILE_ROWS * STATIC_TACTILE_COLS # 28
52
+ DYNAMIC_TACTILE_SIZE = 1
53
+ IMU_AXES = 3
54
+ NUM_FINGERS = 2
55
+
56
+
57
+ @dataclass
58
+ class FingerFrame:
59
+ """Raw (non-numpy) values decoded for a single finger from one or more packets."""
60
+
61
+ updated: bool = False
62
+ static_tactile: List[int] = field(default_factory=lambda: [0] * STATIC_TACTILE_SIZE)
63
+ dynamic_tactile: int = 0
64
+ accelerometer: List[int] = field(default_factory=lambda: [0, 0, 0])
65
+ gyroscope: List[int] = field(default_factory=lambda: [0, 0, 0])
66
+ magnetometer: List[int] = field(default_factory=lambda: [0, 0, 0])
67
+ temperature: int = 0
68
+ timestamp: int = 0
69
+
70
+
71
+ def _extract_uint16(data: bytes, count: int) -> Tuple[List[int], int]:
72
+ values = []
73
+ n = min(len(data), count * 2)
74
+ for i in range(0, n - (n % 2), 2):
75
+ values.append((data[i] << 8) | data[i + 1])
76
+ return values, n - (n % 2)
77
+
78
+
79
+ def _extract_int16(data: bytes, count: int) -> Tuple[List[int], int]:
80
+ values, consumed = _extract_uint16(data, count)
81
+ return [v - 65536 if v >= 32768 else v for v in values], consumed
82
+
83
+
84
+ def _extract_uint64(data: bytes, count: int) -> Tuple[List[int], int]:
85
+ values = []
86
+ n = min((len(data) // 8) * 8, count * 8)
87
+ for i in range(0, n, 8):
88
+ value = 0
89
+ for b in data[i : i + 8]:
90
+ value = (value << 8) | b
91
+ values.append(value)
92
+ return values, n
93
+
94
+
95
+ class PacketParser:
96
+ """Buffers raw serial bytes into complete packets and decodes sensor fields."""
97
+
98
+ def __init__(self, num_fingers: int = NUM_FINGERS):
99
+ self._buffer = bytearray()
100
+ self.num_fingers = num_fingers
101
+ self.fingers: List[FingerFrame] = [FingerFrame() for _ in range(num_fingers)]
102
+ self.packet_count = 0
103
+
104
+ # -- outgoing commands ------------------------------------------------
105
+
106
+ @staticmethod
107
+ def build_command(command: int, data: bytes = b"") -> bytes:
108
+ if len(data) > MAX_DATA_SIZE:
109
+ raise ValueError(f"Data too large: {len(data)} > {MAX_DATA_SIZE}")
110
+ packet = bytearray([START_BYTE, 0x00, command, len(data)])
111
+ packet.extend(data)
112
+ return bytes(packet)
113
+
114
+ @classmethod
115
+ def build_get_version_command(cls) -> bytes:
116
+ return cls.build_command(COMMAND_GET_VERSION)
117
+
118
+ @classmethod
119
+ def build_autosend_command(cls, period_ms: int, synchronous: bool = True) -> bytes:
120
+ if not 0 <= period_ms <= 255:
121
+ raise ValueError(f"period_ms must be in [0, 255], got {period_ms}")
122
+ command = COMMAND_AUTOSEND_SYNC if synchronous else COMMAND_AUTOSEND_ASYNC
123
+ return cls.build_command(command, bytes([period_ms]))
124
+
125
+ # -- incoming bytes -----------------------------------------------------
126
+
127
+ def feed(self, data: bytes) -> List[bytes]:
128
+ """Feed raw serial bytes; returns any complete packets found (header+payload)."""
129
+ self._buffer.extend(data)
130
+ packets: List[bytes] = []
131
+
132
+ while len(self._buffer) >= HEADER_SIZE:
133
+ start = self._buffer.find(START_BYTE)
134
+ if start == -1:
135
+ self._buffer.clear()
136
+ break
137
+ if start > 0:
138
+ del self._buffer[:start]
139
+ if len(self._buffer) < HEADER_SIZE:
140
+ break
141
+
142
+ data_length = self._buffer[3]
143
+ packet_size = HEADER_SIZE + data_length
144
+ if len(self._buffer) < packet_size:
145
+ break
146
+
147
+ packets.append(bytes(self._buffer[:packet_size]))
148
+ del self._buffer[:packet_size]
149
+ self.packet_count += 1
150
+
151
+ return packets
152
+
153
+ def reset_buffer(self) -> None:
154
+ self._buffer.clear()
155
+
156
+ def parse_sensor_packet(self, packet: bytes) -> List[bool]:
157
+ """Decode a sensor-data packet, updating self.fingers in place.
158
+
159
+ Returns one bool per finger: True if that finger received a field in
160
+ this packet (mirrors upstream's `newDataAvailable` semantics).
161
+ """
162
+ if len(packet) < HEADER_SIZE:
163
+ return [False] * self.num_fingers
164
+
165
+ data = packet[HEADER_SIZE:]
166
+ idx = 0
167
+ n = len(data)
168
+
169
+ while idx < n:
170
+ sensor_byte = data[idx]
171
+ idx += 1
172
+ sensor_type = sensor_byte & 0xF0
173
+ finger_id = (sensor_byte >> 2) & 0x03
174
+ if finger_id >= self.num_fingers:
175
+ continue
176
+
177
+ finger = self.fingers[finger_id]
178
+ finger.updated = True
179
+ remaining = data[idx:]
180
+
181
+ if sensor_type == SENSOR_STATIC_TACTILE:
182
+ values, consumed = _extract_uint16(remaining, STATIC_TACTILE_SIZE)
183
+ if len(values) == STATIC_TACTILE_SIZE:
184
+ finger.static_tactile = values
185
+ idx += consumed
186
+ elif sensor_type == SENSOR_DYNAMIC_TACTILE:
187
+ values, consumed = _extract_int16(remaining, DYNAMIC_TACTILE_SIZE)
188
+ if values:
189
+ finger.dynamic_tactile = values[0]
190
+ idx += consumed
191
+ elif sensor_type == SENSOR_ACCELEROMETER:
192
+ values, consumed = _extract_int16(remaining, IMU_AXES)
193
+ if len(values) == IMU_AXES:
194
+ finger.accelerometer = values
195
+ idx += consumed
196
+ elif sensor_type == SENSOR_GYROSCOPE:
197
+ values, consumed = _extract_int16(remaining, IMU_AXES)
198
+ if len(values) == IMU_AXES:
199
+ finger.gyroscope = values
200
+ idx += consumed
201
+ elif sensor_type == SENSOR_MAGNETOMETER:
202
+ values, consumed = _extract_int16(remaining, IMU_AXES)
203
+ if len(values) == IMU_AXES:
204
+ finger.magnetometer = values
205
+ idx += consumed
206
+ elif sensor_type == SENSOR_TEMPERATURE:
207
+ values, consumed = _extract_int16(remaining, 1)
208
+ if values:
209
+ finger.temperature = values[0]
210
+ idx += consumed
211
+ elif sensor_type == SENSOR_TIMESTAMP:
212
+ values, consumed = _extract_uint64(remaining, 1)
213
+ if values:
214
+ finger.timestamp = values[0]
215
+ idx += consumed
216
+ else:
217
+ # Unknown field type: stop, we can't know its size.
218
+ break
219
+
220
+ return [f.updated for f in self.fingers]
221
+
222
+ def clear_updated_flags(self) -> None:
223
+ for f in self.fingers:
224
+ f.updated = False
@@ -0,0 +1,204 @@
1
+ """High-level Python interface for the Robotiq TSF-85 tactile sensor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Optional, Tuple
7
+
8
+ import numpy as np
9
+
10
+ from .data import SensorReading
11
+ from .exceptions import SensorConnectionError, SensorNotFoundError
12
+ from .protocol import BAUD_RATE, PacketParser, NUM_FINGERS
13
+
14
+ try: # pragma: no cover - optional dependency
15
+ import serial # type: ignore
16
+ except ImportError: # pragma: no cover - optional dependency
17
+ serial = None # type: ignore
18
+
19
+
20
+ __all__ = ["RobotiqTactileSensor"]
21
+
22
+
23
+ class RobotiqTactileSensor:
24
+ """Convenience wrapper around the low-level TSF-85 packet protocol.
25
+
26
+ The class exposes the main operations requested for a Python package:
27
+ - connect/setup communication,
28
+ - configure the sensor's autosend loop,
29
+ - retrieve pressure as a NumPy array,
30
+ - retrieve accelerometer and gyroscope data,
31
+ - expose the full decoded frame as a :class:`SensorReading`.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ serial_port: Optional[object] = None,
37
+ port: Optional[str] = None,
38
+ baud_rate: int = BAUD_RATE,
39
+ timeout: float = 0.5,
40
+ num_fingers: int = NUM_FINGERS,
41
+ ) -> None:
42
+ self.serial_port = serial_port
43
+ self.port = port
44
+ self.baud_rate = baud_rate
45
+ self.timeout = timeout
46
+ self.parser = PacketParser(num_fingers=num_fingers)
47
+ self._latest_reading: Optional[SensorReading] = None
48
+ self._connected = False
49
+
50
+ def __enter__(self) -> "RobotiqTactileSensor":
51
+ self.connect()
52
+ return self
53
+
54
+ def __exit__(self, exc_type, exc, tb) -> None:
55
+ self.close()
56
+
57
+ def discover_ports(self) -> list[str]:
58
+ """Return a list of currently available serial ports."""
59
+ if serial is None:
60
+ return []
61
+ return [port.device for port in serial.tools.list_ports.comports()]
62
+
63
+ def connect(self, port: Optional[str] = None, baud_rate: Optional[int] = None) -> "RobotiqTactileSensor":
64
+ """Open the serial connection to the sensor.
65
+
66
+ When ``serial_port`` was passed in at construction time, it is reused.
67
+ Otherwise this will try to auto-detect a port and use :mod:`pyserial`.
68
+ """
69
+ if self.serial_port is not None:
70
+ if not getattr(self.serial_port, "is_open", True):
71
+ if hasattr(self.serial_port, "open"):
72
+ self.serial_port.open()
73
+ self._connected = True
74
+ return self
75
+
76
+ if serial is None:
77
+ raise ImportError("pyserial is required to open a hardware connection")
78
+
79
+ if port is None:
80
+ port = self.port
81
+ if port is None:
82
+ candidates = self.discover_ports()
83
+ if not candidates:
84
+ raise SensorNotFoundError("No serial port discovered for the TSF-85 sensor")
85
+ port = candidates[0]
86
+
87
+ if baud_rate is None:
88
+ baud_rate = self.baud_rate
89
+
90
+ self.serial_port = serial.Serial(port=port, baudrate=baud_rate, timeout=self.timeout)
91
+ self._connected = True
92
+ return self
93
+
94
+ def setup_communication(self, port: Optional[str] = None, baud_rate: Optional[int] = None) -> "RobotiqTactileSensor":
95
+ """Alias for :meth:`connect` that matches the package's public API."""
96
+ return self.connect(port=port, baud_rate=baud_rate)
97
+
98
+ def close(self) -> None:
99
+ """Close the underlying serial connection if it exists."""
100
+ if self.serial_port is not None and hasattr(self.serial_port, "close"):
101
+ self.serial_port.close()
102
+ self._connected = False
103
+
104
+ def configure(self, update_period_ms: int = 50, synchronous: bool = True) -> "RobotiqTactileSensor":
105
+ """Configure the sensor to stream data using the upstream autosend command.
106
+
107
+ The Robotiq firmware exposes several sensor settings; this wrapper
108
+ currently exposes the most useful general-purpose configuration option:
109
+ the autosend update period.
110
+ """
111
+ self._ensure_connected()
112
+ command = PacketParser.build_autosend_command(update_period_ms, synchronous=synchronous)
113
+ self._write(command)
114
+ return self
115
+
116
+ def get_version(self) -> Optional[bytes]:
117
+ """Request the firmware version and return the raw response payload."""
118
+ self._ensure_connected()
119
+ self._write(PacketParser.build_get_version_command())
120
+ return self._read_response(timeout=self.timeout)
121
+
122
+ def read_frame(self, timeout: Optional[float] = None) -> SensorReading:
123
+ """Read one decoded sensor frame and return it as :class:`SensorReading`."""
124
+ self._ensure_connected()
125
+ deadline = None if timeout is None else time.monotonic() + timeout
126
+
127
+ while True:
128
+ data = self._read_bytes()
129
+ if not data:
130
+ if deadline is not None and time.monotonic() >= deadline:
131
+ break
132
+ time.sleep(0.001)
133
+ continue
134
+
135
+ for packet in self.parser.feed(data):
136
+ self.parser.parse_sensor_packet(packet)
137
+
138
+ if self.parser.packet_count:
139
+ break
140
+
141
+ if deadline is not None and time.monotonic() >= deadline:
142
+ break
143
+
144
+ self.parser.clear_updated_flags()
145
+ reading = SensorReading.from_fingers(self.parser.fingers)
146
+ self._latest_reading = reading
147
+ return reading
148
+
149
+ def get_pressure(self) -> np.ndarray:
150
+ """Return the latest static pressure grid as a NumPy array."""
151
+ return self.read_frame().pressure
152
+
153
+ def get_accelerometer(self) -> np.ndarray:
154
+ """Return the latest accelerometer values as a NumPy array."""
155
+ return self.read_frame().accelerometer
156
+
157
+ def get_gyroscope(self) -> np.ndarray:
158
+ """Return the latest gyroscope values as a NumPy array."""
159
+ return self.read_frame().gyroscope
160
+
161
+ def get_imu(self) -> Tuple[np.ndarray, np.ndarray]:
162
+ """Return the latest accelerometer and gyroscope values as a tuple."""
163
+ reading = self.read_frame()
164
+ return reading.accelerometer, reading.gyroscope
165
+
166
+ def get_latest_reading(self) -> Optional[SensorReading]:
167
+ """Return the most recently decoded frame, if any."""
168
+ return self._latest_reading
169
+
170
+ def _ensure_connected(self) -> None:
171
+ if not self._connected:
172
+ self.connect()
173
+
174
+ def _write(self, payload: bytes) -> None:
175
+ if self.serial_port is None:
176
+ raise SensorConnectionError("The sensor is not connected")
177
+ if getattr(self.serial_port, "is_open", True) is False:
178
+ raise SensorConnectionError("The sensor serial connection is closed")
179
+
180
+ written = self.serial_port.write(payload)
181
+ if written != len(payload):
182
+ raise SensorConnectionError("Failed to write the full payload to the sensor")
183
+
184
+ def _read_bytes(self) -> bytes:
185
+ if self.serial_port is None:
186
+ raise SensorConnectionError("The sensor is not connected")
187
+
188
+ if getattr(self.serial_port, "is_open", True) is False:
189
+ raise SensorConnectionError("The sensor serial connection is closed")
190
+
191
+ if hasattr(self.serial_port, "in_waiting"):
192
+ if self.serial_port.in_waiting > 0:
193
+ return self.serial_port.read(self.serial_port.in_waiting)
194
+
195
+ return self.serial_port.read(1)
196
+
197
+ def _read_response(self, timeout: float) -> Optional[bytes]:
198
+ deadline = time.monotonic() + timeout
199
+ while time.monotonic() < deadline:
200
+ data = self._read_bytes()
201
+ if data:
202
+ return data
203
+ time.sleep(0.001)
204
+ return None
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyrobotiqtactile
3
+ Version: 0.1.0
4
+ Summary: Python support for the Robotiq TSF-85 tactile sensor
5
+ Author: Benoit CASTETS
6
+ License-Expression: BSD-3-Clause
7
+ Project-URL: Homepage, https://github.com/bcastets-robotiq/pyRobotiqTactile
8
+ Project-URL: Repository, https://github.com/bcastets-robotiq/pyRobotiqTactile
9
+ Project-URL: Issues, https://github.com/bcastets-robotiq/pyRobotiqTactile/issues
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Scientific/Engineering
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENCE
20
+ Requires-Dist: numpy>=1.26
21
+ Requires-Dist: pyserial>=3.5
22
+ Dynamic: license-file
23
+
24
+ # pyRobotiqTactile
25
+
26
+ A Python package for communicating with the Robotiq TSF-85 tactile sensor.
27
+
28
+ The project focuses on a public `RobotiqTactileSensor` class that provides:
29
+
30
+ - serial communication setup
31
+ - sensor configuration and autosend control
32
+ - pressure-frame decoding as NumPy arrays
33
+ - accelerometer and gyroscope access
34
+ - a structured `SensorReading` container for the decoded data
35
+
36
+ ## Quick start
37
+
38
+ ```python
39
+ from pyrobotiqtactile import RobotiqTactileSensor
40
+
41
+ sensor = RobotiqTactileSensor()
42
+ sensor.connect("COM3")
43
+ sensor.configure(update_period_ms=50, synchronous=True)
44
+
45
+ reading = sensor.read_frame()
46
+ print(reading.pressure.shape)
47
+ print(sensor.get_imu())
48
+ ```
49
+
50
+ ## Development
51
+
52
+ ```bash
53
+ pip install -e .
54
+ python -m unittest discover -s tests -v
55
+ ```
@@ -0,0 +1,14 @@
1
+ LICENCE
2
+ README.md
3
+ pyproject.toml
4
+ src/pyrobotiqtactile/__init__.py
5
+ src/pyrobotiqtactile/data.py
6
+ src/pyrobotiqtactile/exceptions.py
7
+ src/pyrobotiqtactile/protocol.py
8
+ src/pyrobotiqtactile/sensor.py
9
+ src/pyrobotiqtactile.egg-info/PKG-INFO
10
+ src/pyrobotiqtactile.egg-info/SOURCES.txt
11
+ src/pyrobotiqtactile.egg-info/dependency_links.txt
12
+ src/pyrobotiqtactile.egg-info/requires.txt
13
+ src/pyrobotiqtactile.egg-info/top_level.txt
14
+ tests/test_sensor.py
@@ -0,0 +1,2 @@
1
+ numpy>=1.26
2
+ pyserial>=3.5
@@ -0,0 +1 @@
1
+ pyrobotiqtactile
@@ -0,0 +1,75 @@
1
+ import unittest
2
+
3
+ from pyrobotiqtactile import RobotiqTactileSensor
4
+ from pyrobotiqtactile.protocol import (
5
+ IMU_AXES,
6
+ SENSOR_ACCELEROMETER,
7
+ SENSOR_STATIC_TACTILE,
8
+ STATIC_TACTILE_COLS,
9
+ STATIC_TACTILE_ROWS,
10
+ STATIC_TACTILE_SIZE,
11
+ )
12
+
13
+
14
+ class FakeSerial:
15
+ def __init__(self, packets):
16
+ self._packets = list(packets)
17
+ self.writes = []
18
+ self._is_open = True
19
+
20
+ def write(self, data):
21
+ self.writes.append(data)
22
+ return len(data)
23
+
24
+ def read(self, size=1):
25
+ if self._packets:
26
+ return self._packets.pop(0)
27
+ return b""
28
+
29
+ @property
30
+ def in_waiting(self):
31
+ return len(self._packets)
32
+
33
+ @property
34
+ def is_open(self):
35
+ return self._is_open
36
+
37
+ def close(self):
38
+ self._is_open = False
39
+
40
+
41
+ class RobotiqTactileSensorTests(unittest.TestCase):
42
+ def test_read_frame_returns_pressure_and_imu_arrays(self):
43
+ static_payload = bytearray()
44
+ for value in range(STATIC_TACTILE_SIZE):
45
+ static_payload.append(((value + 1) & 0xFF00) >> 8)
46
+ static_payload.append((value + 1) & 0xFF)
47
+
48
+ accel_payload = bytearray()
49
+ for value in (1, -2, 3):
50
+ accel_payload.append(((value & 0xFFFF) >> 8) & 0xFF)
51
+ accel_payload.append(value & 0xFF)
52
+
53
+ payload = bytes([SENSOR_STATIC_TACTILE | 0x00]) + bytes(static_payload) + bytes([SENSOR_ACCELEROMETER | 0x00]) + bytes(accel_payload)
54
+ packet = bytes([0x9A, 0x00, 0x61, len(payload)]) + payload
55
+
56
+ sensor = RobotiqTactileSensor(serial_port=FakeSerial([packet]))
57
+ reading = sensor.read_frame()
58
+
59
+ self.assertEqual(reading.pressure.shape, (2, STATIC_TACTILE_ROWS, STATIC_TACTILE_COLS))
60
+ self.assertEqual(reading.accelerometer.shape, (2, IMU_AXES))
61
+ self.assertEqual(reading.gyroscope.shape, (2, IMU_AXES))
62
+ self.assertEqual(reading.pressure[0, 0, 0], 1)
63
+ self.assertEqual(reading.accelerometer[0, 0], 1)
64
+ self.assertEqual(reading.accelerometer[0, 1], -2)
65
+
66
+ def test_configure_sends_autosend_command(self):
67
+ sensor = RobotiqTactileSensor(serial_port=FakeSerial([]))
68
+ sensor.configure(update_period_ms=50, synchronous=True)
69
+
70
+ self.assertTrue(sensor.serial_port.writes)
71
+ self.assertEqual(sensor.serial_port.writes[-1][2], 0x58)
72
+
73
+
74
+ if __name__ == "__main__":
75
+ unittest.main()