yostlabs 2025.10.6__tar.gz → 2025.10.29__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.
- {yostlabs-2025.10.6 → yostlabs-2025.10.29}/PKG-INFO +2 -1
- {yostlabs-2025.10.6 → yostlabs-2025.10.29}/pyproject.toml +3 -2
- {yostlabs-2025.10.6 → yostlabs-2025.10.29}/src/yostlabs/communication/ble.py +117 -13
- yostlabs-2025.10.29/src/yostlabs/communication/bluetooth.py +203 -0
- {yostlabs-2025.10.6 → yostlabs-2025.10.29}/src/yostlabs/communication/serial.py +40 -9
- yostlabs-2025.10.29/src/yostlabs/communication/socket.py +118 -0
- {yostlabs-2025.10.6 → yostlabs-2025.10.29}/src/yostlabs/tss3/api.py +6 -1
- {yostlabs-2025.10.6 → yostlabs-2025.10.29}/src/yostlabs/tss3/consts.py +3 -1
- {yostlabs-2025.10.6 → yostlabs-2025.10.29}/.gitignore +0 -0
- {yostlabs-2025.10.6 → yostlabs-2025.10.29}/Examples/embedded_2024_dec_20.xml +0 -0
- {yostlabs-2025.10.6 → yostlabs-2025.10.29}/Examples/example_ble.py +0 -0
- {yostlabs-2025.10.6 → yostlabs-2025.10.29}/Examples/example_commands.py +0 -0
- {yostlabs-2025.10.6 → yostlabs-2025.10.29}/Examples/example_component_specific_settings_and_commands.py +0 -0
- {yostlabs-2025.10.6 → yostlabs-2025.10.29}/Examples/example_firmware_upload.py +0 -0
- {yostlabs-2025.10.6 → yostlabs-2025.10.29}/Examples/example_parsing_stored_binary.py +0 -0
- {yostlabs-2025.10.6 → yostlabs-2025.10.29}/Examples/example_read_settings.py +0 -0
- {yostlabs-2025.10.6 → yostlabs-2025.10.29}/Examples/example_streaming.py +0 -0
- {yostlabs-2025.10.6 → yostlabs-2025.10.29}/Examples/example_streaming_manager.py +0 -0
- {yostlabs-2025.10.6 → yostlabs-2025.10.29}/Examples/example_write_settings.py +0 -0
- {yostlabs-2025.10.6 → yostlabs-2025.10.29}/LICENSE +0 -0
- {yostlabs-2025.10.6 → yostlabs-2025.10.29}/README.md +0 -0
- {yostlabs-2025.10.6 → yostlabs-2025.10.29}/src/yostlabs/__init__.py +0 -0
- {yostlabs-2025.10.6 → yostlabs-2025.10.29}/src/yostlabs/communication/__init__.py +0 -0
- {yostlabs-2025.10.6 → yostlabs-2025.10.29}/src/yostlabs/communication/base.py +0 -0
- {yostlabs-2025.10.6 → yostlabs-2025.10.29}/src/yostlabs/math/__init__.py +0 -0
- {yostlabs-2025.10.6 → yostlabs-2025.10.29}/src/yostlabs/math/quaternion.py +0 -0
- {yostlabs-2025.10.6 → yostlabs-2025.10.29}/src/yostlabs/math/vector.py +0 -0
- {yostlabs-2025.10.6 → yostlabs-2025.10.29}/src/yostlabs/tss3/__init__.py +0 -0
- {yostlabs-2025.10.6 → yostlabs-2025.10.29}/src/yostlabs/tss3/eepts.py +0 -0
- {yostlabs-2025.10.6 → yostlabs-2025.10.29}/src/yostlabs/tss3/utils/__init__.py +0 -0
- {yostlabs-2025.10.6 → yostlabs-2025.10.29}/src/yostlabs/tss3/utils/calibration.py +0 -0
- {yostlabs-2025.10.6 → yostlabs-2025.10.29}/src/yostlabs/tss3/utils/parser.py +0 -0
- {yostlabs-2025.10.6 → yostlabs-2025.10.29}/src/yostlabs/tss3/utils/streaming.py +0 -0
- {yostlabs-2025.10.6 → yostlabs-2025.10.29}/src/yostlabs/tss3/utils/version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: yostlabs
|
|
3
|
-
Version: 2025.10.
|
|
3
|
+
Version: 2025.10.29
|
|
4
4
|
Summary: Python resources and API for 3Space sensors from Yost Labs Inc.
|
|
5
5
|
Project-URL: Homepage, https://yostlabs.com/
|
|
6
6
|
Project-URL: Repository, https://github.com/YostLabs/3SpacePythonPackage/tree/main
|
|
@@ -16,6 +16,7 @@ Requires-Python: >=3.10
|
|
|
16
16
|
Requires-Dist: async-timeout
|
|
17
17
|
Requires-Dist: bleak
|
|
18
18
|
Requires-Dist: numpy
|
|
19
|
+
Requires-Dist: pybluez2
|
|
19
20
|
Requires-Dist: pyserial
|
|
20
21
|
Description-Content-Type: text/markdown
|
|
21
22
|
|
|
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "yostlabs"
|
|
7
7
|
#If uploading again on the same day, add a fourth number
|
|
8
|
-
version = "2025.10.
|
|
8
|
+
version = "2025.10.29"
|
|
9
9
|
authors = [
|
|
10
10
|
{ name="Yost Labs Inc.", email="techsupport@yostlabs.com" },
|
|
11
11
|
{ name="Andy Riedlinger", email="techsupport@yostlabs.com" },
|
|
@@ -24,7 +24,8 @@ dependencies = [
|
|
|
24
24
|
"pyserial",
|
|
25
25
|
"numpy",
|
|
26
26
|
"bleak",
|
|
27
|
-
"async-timeout"
|
|
27
|
+
"async-timeout",
|
|
28
|
+
"pybluez2"
|
|
28
29
|
]
|
|
29
30
|
|
|
30
31
|
[project.urls]
|
|
@@ -7,6 +7,7 @@ from bleak.backends.device import BLEDevice
|
|
|
7
7
|
from bleak.backends.scanner import AdvertisementData
|
|
8
8
|
from bleak.backends.characteristic import BleakGATTCharacteristic
|
|
9
9
|
from bleak.exc import BleakDeviceNotFoundError
|
|
10
|
+
from dataclasses import dataclass
|
|
10
11
|
|
|
11
12
|
#Services
|
|
12
13
|
NORDIC_UART_SERVICE_UUID = "6e400001-b5a3-f393-e0a9-e50e24dcca9e"
|
|
@@ -23,6 +24,12 @@ HARDWARE_REVISION_STRING_UUID = "00002a27-0000-1000-8000-00805f9b34fb"
|
|
|
23
24
|
SERIAL_NUMBER_STRING_UUID = "00002a25-0000-1000-8000-00805f9b34fb"
|
|
24
25
|
MANUFACTURER_NAME_STRING_UUID = "00002a29-0000-1000-8000-00805f9b34fb"
|
|
25
26
|
|
|
27
|
+
@dataclass
|
|
28
|
+
class ThreespaceBLENordicUartProfile:
|
|
29
|
+
SERVICE_UUID: str
|
|
30
|
+
RX_UUID: str
|
|
31
|
+
TX_UUID: str
|
|
32
|
+
|
|
26
33
|
class TssBLENoConnectionError(Exception): ...
|
|
27
34
|
|
|
28
35
|
def ylBleEventLoopThread(loop: asyncio.AbstractEventLoop):
|
|
@@ -36,6 +43,9 @@ class ThreespaceBLEComClass(ThreespaceComClass):
|
|
|
36
43
|
EVENT_LOOP = None
|
|
37
44
|
EVENT_LOOP_THREAD = None
|
|
38
45
|
|
|
46
|
+
DEFAULT_PROFILE = ThreespaceBLENordicUartProfile(NORDIC_UART_SERVICE_UUID, NORDIC_UART_RX_UUID, NORDIC_UART_TX_UUID)
|
|
47
|
+
REGISTERED_PROFILES: list[ThreespaceBLENordicUartProfile] = [DEFAULT_PROFILE]
|
|
48
|
+
|
|
39
49
|
@classmethod
|
|
40
50
|
def __lazy_event_loop_init(cls):
|
|
41
51
|
if cls.EVENT_LOOP is None:
|
|
@@ -43,7 +53,7 @@ class ThreespaceBLEComClass(ThreespaceComClass):
|
|
|
43
53
|
cls.EVENT_LOOP_THREAD = threading.Thread(target=ylBleEventLoopThread, args=(cls.EVENT_LOOP,), daemon=True)
|
|
44
54
|
cls.EVENT_LOOP_THREAD.start()
|
|
45
55
|
|
|
46
|
-
def __init__(self, ble: BleakClient | BLEDevice | str, discover_name: bool = True, discovery_timeout=5, error_on_disconnect=True):
|
|
56
|
+
def __init__(self, ble: BleakClient | BLEDevice | str, discover_name: bool = True, discovery_timeout=5, error_on_disconnect=True, adv: AdvertisementData = None, profile: ThreespaceBLENordicUartProfile=None):
|
|
47
57
|
"""
|
|
48
58
|
Parameters
|
|
49
59
|
----------
|
|
@@ -54,6 +64,7 @@ class ThreespaceBLEComClass(ThreespaceComClass):
|
|
|
54
64
|
if it is expected that the sensor will frequently go in and out of range and the user wishes to preserve data (such as streaming)
|
|
55
65
|
"""
|
|
56
66
|
self.__lazy_event_loop_init()
|
|
67
|
+
self.adv = adv
|
|
57
68
|
bleak_options = { "timeout": discovery_timeout, "disconnected_callback": self.__on_disconnect }
|
|
58
69
|
if isinstance(ble, BleakClient): #Actual client
|
|
59
70
|
self.client = ble
|
|
@@ -76,6 +87,20 @@ class ThreespaceBLEComClass(ThreespaceComClass):
|
|
|
76
87
|
else:
|
|
77
88
|
raise TypeError("Invalid type for creating a ThreespaceBLEComClass:", type(ble), ble)
|
|
78
89
|
|
|
90
|
+
#Select the profile
|
|
91
|
+
self.profile = profile
|
|
92
|
+
if self.profile is None:
|
|
93
|
+
if self.adv is not None and len(self.adv.service_uuids) > 0:
|
|
94
|
+
for service_uuid in self.adv.service_uuids:
|
|
95
|
+
self.profile = self.get_profile(service_uuid)
|
|
96
|
+
if self.profile is not None:
|
|
97
|
+
break
|
|
98
|
+
if self.profile is None:
|
|
99
|
+
self.profile = ThreespaceBLEComClass.DEFAULT_PROFILE
|
|
100
|
+
raise Exception(f"Unknown Service UUIDS: {self.adv.service_uuids}")
|
|
101
|
+
else:
|
|
102
|
+
self.profile = ThreespaceBLEComClass.DEFAULT_PROFILE
|
|
103
|
+
|
|
79
104
|
self.__timeout = self.DEFAULT_TIMEOUT
|
|
80
105
|
|
|
81
106
|
self.buffer = bytearray()
|
|
@@ -101,7 +126,7 @@ class ThreespaceBLEComClass(ThreespaceComClass):
|
|
|
101
126
|
async def __async_open(self):
|
|
102
127
|
self.data_read_event = asyncio.Event()
|
|
103
128
|
await self.client.connect()
|
|
104
|
-
await self.client.start_notify(
|
|
129
|
+
await self.client.start_notify(self.profile.TX_UUID, self.__on_data_received)
|
|
105
130
|
|
|
106
131
|
def open(self):
|
|
107
132
|
#If trying to open while already open, this infinitely loops
|
|
@@ -151,7 +176,7 @@ class ThreespaceBLEComClass(ThreespaceComClass):
|
|
|
151
176
|
while start_index < len(bytes):
|
|
152
177
|
end_index = min(len(bytes), start_index + self.max_packet_size) #Can only send max_packet_size data per call to write_gatt_char
|
|
153
178
|
asyncio.run_coroutine_threadsafe(
|
|
154
|
-
self.client.write_gatt_char(
|
|
179
|
+
self.client.write_gatt_char(self.profile.RX_UUID, bytes[start_index:end_index], response=False),
|
|
155
180
|
self.EVENT_LOOP).result()
|
|
156
181
|
start_index = end_index
|
|
157
182
|
|
|
@@ -244,8 +269,16 @@ class ThreespaceBLEComClass(ThreespaceComClass):
|
|
|
244
269
|
def address(self) -> str:
|
|
245
270
|
return self.client.address
|
|
246
271
|
|
|
247
|
-
|
|
272
|
+
@classmethod
|
|
273
|
+
def get_profile(cls, service_uuid: str):
|
|
274
|
+
for profile in cls.REGISTERED_PROFILES:
|
|
275
|
+
if profile.SERVICE_UUID == service_uuid:
|
|
276
|
+
return profile
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
SCANNER: BleakScanner = None
|
|
248
280
|
SCANNER_LOCK = None
|
|
281
|
+
SCANNER_RUNNING = False
|
|
249
282
|
|
|
250
283
|
SCANNER_CONTINOUS = False #Controls if scanning will continously run
|
|
251
284
|
SCANNER_TIMEOUT = 5 #Controls the scanners timeout
|
|
@@ -255,23 +288,94 @@ class ThreespaceBLEComClass(ThreespaceComClass):
|
|
|
255
288
|
#Format: Address - dict = { device: ..., adv: ..., last_found: ... }
|
|
256
289
|
discovered_devices: dict[str,dict] = {}
|
|
257
290
|
|
|
291
|
+
@classmethod
|
|
292
|
+
def set_profiles(cls, profiles: list[ThreespaceBLENordicUartProfile]):
|
|
293
|
+
cls.REGISTERED_PROFILES = profiles
|
|
294
|
+
if cls.SCANNER is not None:
|
|
295
|
+
asyncio.run_coroutine_threadsafe(cls.create_scanner(), cls.EVENT_LOOP).result()
|
|
296
|
+
cls.__remove_unused_profiles()
|
|
297
|
+
|
|
298
|
+
@classmethod
|
|
299
|
+
def register_profile(cls, profile: ThreespaceBLENordicUartProfile):
|
|
300
|
+
if any(v.SERVICE_UUID == profile.SERVICE_UUID for v in cls.REGISTERED_PROFILES): return
|
|
301
|
+
cls.REGISTERED_PROFILES.append(profile)
|
|
302
|
+
if cls.SCANNER is not None:
|
|
303
|
+
asyncio.run_coroutine_threadsafe(cls.create_scanner(), cls.EVENT_LOOP).result()
|
|
304
|
+
|
|
305
|
+
@classmethod
|
|
306
|
+
def unregister_profile(cls, service_uuid: str|ThreespaceBLENordicUartProfile):
|
|
307
|
+
if isinstance(service_uuid, ThreespaceBLENordicUartProfile):
|
|
308
|
+
service_uuid = service_uuid.SERVICE_UUID
|
|
309
|
+
index = None
|
|
310
|
+
for i in range(len(cls.REGISTERED_PROFILES)):
|
|
311
|
+
if cls.REGISTERED_PROFILES[i].SERVICE_UUID == service_uuid:
|
|
312
|
+
index = i
|
|
313
|
+
break
|
|
314
|
+
del cls.REGISTERED_PROFILES[index]
|
|
315
|
+
if cls.SCANNER is not None:
|
|
316
|
+
asyncio.run_coroutine_threadsafe(cls.create_scanner(), cls.EVENT_LOOP).result()
|
|
317
|
+
cls.__remove_unused_profiles()
|
|
318
|
+
|
|
319
|
+
@classmethod
|
|
320
|
+
def __remove_unused_profiles(cls):
|
|
321
|
+
if cls.SCANNER is None: return
|
|
322
|
+
to_remove = []
|
|
323
|
+
valid_service_uuids = [v.SERVICE_UUID for v in cls.REGISTERED_PROFILES]
|
|
324
|
+
with cls.SCANNER_LOCK:
|
|
325
|
+
for address in cls.discovered_devices:
|
|
326
|
+
adv: AdvertisementData = cls.discovered_devices[address]["adv"]
|
|
327
|
+
if not any(uuid in valid_service_uuids for uuid in adv.service_uuids):
|
|
328
|
+
to_remove.append(address)
|
|
329
|
+
for address in to_remove:
|
|
330
|
+
del cls.discovered_devices[address]
|
|
331
|
+
|
|
332
|
+
#Scanner should be created inside of the Async Context that will use it
|
|
333
|
+
@classmethod
|
|
334
|
+
async def create_scanner(cls):
|
|
335
|
+
uuids = [v.SERVICE_UUID for v in cls.REGISTERED_PROFILES]
|
|
336
|
+
restart_scanner = cls.SCANNER_RUNNING
|
|
337
|
+
with cls.SCANNER_LOCK:
|
|
338
|
+
if restart_scanner:
|
|
339
|
+
await cls.__async_stop_scanner()
|
|
340
|
+
cls.SCANNER = BleakScanner(detection_callback=cls.__detection_callback, service_uuids=uuids)
|
|
341
|
+
if restart_scanner:
|
|
342
|
+
await cls.__async_start_scanner()
|
|
343
|
+
|
|
258
344
|
@classmethod
|
|
259
345
|
def __lazy_init_scanner(cls):
|
|
260
346
|
cls.__lazy_event_loop_init()
|
|
261
347
|
if cls.SCANNER is None:
|
|
262
348
|
cls.SCANNER_LOCK = threading.Lock()
|
|
263
|
-
|
|
264
|
-
async def create_scanner():
|
|
265
|
-
cls.SCANNER = BleakScanner(detection_callback=cls.__detection_callback, service_uuids=[NORDIC_UART_SERVICE_UUID])
|
|
266
|
-
asyncio.run_coroutine_threadsafe(create_scanner(), cls.EVENT_LOOP).result()
|
|
267
|
-
|
|
268
|
-
|
|
349
|
+
asyncio.run_coroutine_threadsafe(cls.create_scanner(), cls.EVENT_LOOP).result()
|
|
269
350
|
|
|
270
351
|
@classmethod
|
|
271
352
|
def __detection_callback(cls, device: BLEDevice, adv: AdvertisementData):
|
|
272
353
|
with cls.SCANNER_LOCK:
|
|
273
354
|
cls.discovered_devices[device.address] = {"device": device, "adv": adv, "last_found": time.time()}
|
|
274
355
|
|
|
356
|
+
@classmethod
|
|
357
|
+
async def __async_start_scanner(cls):
|
|
358
|
+
if cls.SCANNER_RUNNING: return
|
|
359
|
+
await cls.SCANNER.start()
|
|
360
|
+
cls.SCANNER_RUNNING = True
|
|
361
|
+
|
|
362
|
+
@classmethod
|
|
363
|
+
async def __async_stop_scanner(cls):
|
|
364
|
+
if not cls.SCANNER_RUNNING: return
|
|
365
|
+
await cls.SCANNER.stop()
|
|
366
|
+
cls.SCANNER_RUNNING = False
|
|
367
|
+
|
|
368
|
+
@classmethod
|
|
369
|
+
def __start_scanner(cls):
|
|
370
|
+
if cls.SCANNER_RUNNING: return
|
|
371
|
+
asyncio.run_coroutine_threadsafe(cls.__async_start_scanner(), cls.EVENT_LOOP).result()
|
|
372
|
+
|
|
373
|
+
@classmethod
|
|
374
|
+
def __stop_scanner(cls):
|
|
375
|
+
if not cls.SCANNER_RUNNING: return
|
|
376
|
+
asyncio.run_coroutine_threadsafe(cls.__async_stop_scanner(), cls.EVENT_LOOP).result()
|
|
377
|
+
cls.__stop_scanner()
|
|
378
|
+
|
|
275
379
|
@classmethod
|
|
276
380
|
def set_scanner_continous(cls, continous: bool):
|
|
277
381
|
"""
|
|
@@ -285,9 +389,9 @@ class ThreespaceBLEComClass(ThreespaceComClass):
|
|
|
285
389
|
cls.__lazy_init_scanner()
|
|
286
390
|
cls.SCANNER_CONTINOUS = continous
|
|
287
391
|
if continous:
|
|
288
|
-
|
|
392
|
+
cls.__start_scanner()
|
|
289
393
|
else:
|
|
290
|
-
|
|
394
|
+
cls.__stop_scanner()
|
|
291
395
|
|
|
292
396
|
@classmethod
|
|
293
397
|
def update_nearby_devices(cls):
|
|
@@ -338,4 +442,4 @@ class ThreespaceBLEComClass(ThreespaceComClass):
|
|
|
338
442
|
cls.update_nearby_devices()
|
|
339
443
|
with cls.SCANNER_LOCK:
|
|
340
444
|
for device_info in cls.discovered_devices.values():
|
|
341
|
-
yield(ThreespaceBLEComClass(device_info["device"]))
|
|
445
|
+
yield(ThreespaceBLEComClass(device_info["device"], adv=device_info["adv"]))
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
from yostlabs.communication.socket import ThreespaceSocketComClass
|
|
2
|
+
import bluetooth
|
|
3
|
+
import time
|
|
4
|
+
import threading
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Callable, Generator
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class COD:
|
|
11
|
+
raw: int
|
|
12
|
+
services: list[str]
|
|
13
|
+
major_class: str
|
|
14
|
+
minor_class: str
|
|
15
|
+
|
|
16
|
+
def decode_class_of_device(cod: int) -> dict:
|
|
17
|
+
"""Decode a Bluetooth Class of Device (CoD) integer into its components."""
|
|
18
|
+
|
|
19
|
+
# Service Class bit masks (11 bits total)
|
|
20
|
+
services = {
|
|
21
|
+
0x002000: "Limited Discoverable Mode",
|
|
22
|
+
0x004000: "Positioning (Location Identification)",
|
|
23
|
+
0x008000: "Networking",
|
|
24
|
+
0x010000: "Rendering",
|
|
25
|
+
0x020000: "Capturing",
|
|
26
|
+
0x040000: "Object Transfer",
|
|
27
|
+
0x080000: "Audio",
|
|
28
|
+
0x100000: "Telephony",
|
|
29
|
+
0x200000: "Information",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# Major Device Classes (5 bits)
|
|
33
|
+
major_classes = {
|
|
34
|
+
0x00: "Miscellaneous",
|
|
35
|
+
0x01: "Computer",
|
|
36
|
+
0x02: "Phone",
|
|
37
|
+
0x03: "LAN/Network Access Point",
|
|
38
|
+
0x04: "Audio/Video",
|
|
39
|
+
0x05: "Peripheral",
|
|
40
|
+
0x06: "Imaging",
|
|
41
|
+
0x07: "Wearable",
|
|
42
|
+
0x08: "Toy",
|
|
43
|
+
0x09: "Health",
|
|
44
|
+
0x1F: "Uncategorized",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# Minor Class mappings for some major classes (for simplicity)
|
|
48
|
+
minor_classes = {
|
|
49
|
+
0x01: {
|
|
50
|
+
0x00: "Uncategorized",
|
|
51
|
+
0x01: "Desktop workstation",
|
|
52
|
+
0x02: "Server-class computer",
|
|
53
|
+
0x03: "Laptop",
|
|
54
|
+
0x04: "Handheld PC/PDA",
|
|
55
|
+
0x05: "Palm-size PC/PDA",
|
|
56
|
+
},
|
|
57
|
+
0x02: {
|
|
58
|
+
0x00: "Uncategorized",
|
|
59
|
+
0x01: "Cellular",
|
|
60
|
+
0x02: "Cordless",
|
|
61
|
+
0x03: "Smartphone",
|
|
62
|
+
0x04: "Wired modem or voice gateway",
|
|
63
|
+
0x05: "Common ISDN access",
|
|
64
|
+
},
|
|
65
|
+
0x05: {
|
|
66
|
+
0x00: "Uncategorized",
|
|
67
|
+
0x01: "Keyboard",
|
|
68
|
+
0x02: "Pointing device",
|
|
69
|
+
0x03: "Combo keyboard/pointing device",
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# Extract fields
|
|
74
|
+
service_bits = cod & 0xFFE000 # top 11 bits
|
|
75
|
+
major_class = (cod >> 8) & 0x1F
|
|
76
|
+
minor_class = (cod >> 2) & 0x3F
|
|
77
|
+
|
|
78
|
+
# Decode services
|
|
79
|
+
decoded_services = [name for bit, name in services.items() if service_bits & bit]
|
|
80
|
+
|
|
81
|
+
# Decode major class
|
|
82
|
+
major_name = major_classes.get(major_class, "Unknown")
|
|
83
|
+
|
|
84
|
+
# Decode minor class (context-dependent)
|
|
85
|
+
minor_name = minor_classes.get(major_class, {}).get(minor_class, f"Minor code {minor_class}")
|
|
86
|
+
|
|
87
|
+
return COD(cod, decoded_services or ["None"], major_name, minor_name)
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class ScannerResult:
|
|
91
|
+
address: str
|
|
92
|
+
name: str
|
|
93
|
+
class_of_device: COD
|
|
94
|
+
|
|
95
|
+
class Scanner:
|
|
96
|
+
|
|
97
|
+
def __init__(self, interval=5):
|
|
98
|
+
self.enabled = False
|
|
99
|
+
self.done = True
|
|
100
|
+
|
|
101
|
+
self.nearby = None
|
|
102
|
+
self.thread = None
|
|
103
|
+
self.updated = False
|
|
104
|
+
self.duration = int(interval / 1.2)
|
|
105
|
+
|
|
106
|
+
def start(self):
|
|
107
|
+
if not self.done: return
|
|
108
|
+
self.thread = threading.Thread(target=self.process, daemon=True)
|
|
109
|
+
self.enabled = True
|
|
110
|
+
self.done = False
|
|
111
|
+
self.updated = False
|
|
112
|
+
self.thread.start()
|
|
113
|
+
|
|
114
|
+
def stop(self):
|
|
115
|
+
self.enabled = False
|
|
116
|
+
|
|
117
|
+
def get_most_recent(self):
|
|
118
|
+
if not self.updated: return None
|
|
119
|
+
self.updated = False
|
|
120
|
+
return self.nearby
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def is_running(self):
|
|
124
|
+
return self.done
|
|
125
|
+
|
|
126
|
+
def process(self):
|
|
127
|
+
while self.enabled:
|
|
128
|
+
nearby = bluetooth.discover_devices(duration=self.duration, lookup_names=True, lookup_class=True)
|
|
129
|
+
self.nearby = [ScannerResult(addr, name, decode_class_of_device(cod)) for addr, name, cod in nearby]
|
|
130
|
+
self.updated = True
|
|
131
|
+
self.done = True
|
|
132
|
+
|
|
133
|
+
class ThreespaceBluetoothComClass(ThreespaceSocketComClass):
|
|
134
|
+
|
|
135
|
+
SCANNER = None
|
|
136
|
+
|
|
137
|
+
def __init__(self, addr: str, name: str = None, connection_timeout=None):
|
|
138
|
+
super().__init__(bluetooth.BluetoothSocket(bluetooth.Protocols.RFCOMM), (addr, 1), connection_timeout=connection_timeout)
|
|
139
|
+
self.address = addr
|
|
140
|
+
self.__name = name or addr
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def name(self) -> str:
|
|
144
|
+
return self.__name
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def __lazy_init_scanner(cls):
|
|
148
|
+
if cls.SCANNER is None:
|
|
149
|
+
cls.SCANNER = Scanner()
|
|
150
|
+
cls.SCANNER.start()
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def __default_filter(result: ScannerResult):
|
|
154
|
+
return result.class_of_device.major_class == "Wearable" and result.class_of_device.minor_class == "Minor code 0"
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def auto_detect(wait_for_update=True, filter: Callable[[ScannerResult],bool] = None) -> Generator["ThreespaceBluetoothComClass", None, None]:
|
|
158
|
+
"""
|
|
159
|
+
Returns a list of com classes of the same type called on nearby
|
|
160
|
+
"""
|
|
161
|
+
cls = ThreespaceBluetoothComClass
|
|
162
|
+
cls.__lazy_init_scanner()
|
|
163
|
+
if filter is None:
|
|
164
|
+
filter = cls.__default_filter
|
|
165
|
+
if wait_for_update:
|
|
166
|
+
cls.SCANNER.updated = False
|
|
167
|
+
while not cls.SCANNER.updated: time.sleep(0.1)
|
|
168
|
+
if cls.SCANNER.nearby is None: return
|
|
169
|
+
for device_info in cls.SCANNER.nearby:
|
|
170
|
+
if not filter(device_info): continue
|
|
171
|
+
name = device_info.name or None
|
|
172
|
+
yield ThreespaceBluetoothComClass(device_info.address, name)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
if __name__ == "__main__":
|
|
176
|
+
from yostlabs.tss3.api import ThreespaceSensor
|
|
177
|
+
|
|
178
|
+
com = None
|
|
179
|
+
for device in ThreespaceBluetoothComClass.auto_detect():
|
|
180
|
+
com = device
|
|
181
|
+
|
|
182
|
+
if com is None:
|
|
183
|
+
print("Failed to detect a bluetooth sensor")
|
|
184
|
+
exit()
|
|
185
|
+
print("Connecting to:", com.name)
|
|
186
|
+
|
|
187
|
+
sensor = ThreespaceSensor(com, verbose=True)
|
|
188
|
+
|
|
189
|
+
sensor.set_settings(stream_slots=0)
|
|
190
|
+
print(sensor.get_settings("stream_slots", "stream_hz"))
|
|
191
|
+
print(sensor.getTaredOrientation())
|
|
192
|
+
|
|
193
|
+
sensor.startStreaming()
|
|
194
|
+
|
|
195
|
+
start_time = time.perf_counter()
|
|
196
|
+
while time.perf_counter() - start_time < 5:
|
|
197
|
+
sensor.updateStreaming()
|
|
198
|
+
packet = sensor.getOldestStreamingPacket()
|
|
199
|
+
while packet is not None:
|
|
200
|
+
print(packet)
|
|
201
|
+
packet = sensor.getOldestStreamingPacket()
|
|
202
|
+
sensor.stopStreaming()
|
|
203
|
+
sensor.cleanup()
|
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
from yostlabs.communication.base import *
|
|
2
2
|
import serial
|
|
3
3
|
import serial.tools.list_ports
|
|
4
|
+
from serial.tools.list_ports_common import ListPortInfo
|
|
4
5
|
import time
|
|
5
6
|
|
|
6
|
-
|
|
7
7
|
class ThreespaceSerialComClass(ThreespaceComClass):
|
|
8
|
-
|
|
9
8
|
PID_V3_MASK = 0x3000
|
|
10
|
-
PID_BOOTLOADER = 0x1000
|
|
11
9
|
|
|
12
10
|
VID = 0x2476
|
|
13
11
|
|
|
12
|
+
PID_BOOTLOADER = 0x1000
|
|
13
|
+
PID_EMBED = 0x3040
|
|
14
|
+
PID_DL = 0x3050
|
|
15
|
+
PID_LX = 0x3090
|
|
16
|
+
|
|
17
|
+
PID_TO_STR_DICT = {
|
|
18
|
+
PID_EMBED: "EM",
|
|
19
|
+
PID_DL: "DL",
|
|
20
|
+
PID_BOOTLOADER: "BOOT",
|
|
21
|
+
PID_LX: "LX"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
14
25
|
DEFAULT_BAUDRATE = 115200
|
|
15
26
|
DEFAULT_TIMEOUT = 2
|
|
16
27
|
|
|
@@ -18,12 +29,11 @@ class ThreespaceSerialComClass(ThreespaceComClass):
|
|
|
18
29
|
if isinstance(ser, serial.Serial):
|
|
19
30
|
self.ser = ser
|
|
20
31
|
elif isinstance(ser, str):
|
|
21
|
-
self.ser = serial.Serial(
|
|
32
|
+
self.ser = serial.Serial(None, baudrate=ThreespaceSerialComClass.DEFAULT_BAUDRATE, timeout=ThreespaceSerialComClass.DEFAULT_TIMEOUT)
|
|
33
|
+
self.ser.port = ser
|
|
22
34
|
else:
|
|
23
35
|
raise TypeError("Invalid type for creating a ThreespaceSerialComClass:", type(ser), ser)
|
|
24
36
|
|
|
25
|
-
self.ser = ser
|
|
26
|
-
|
|
27
37
|
self.peek_buffer = bytearray()
|
|
28
38
|
self.peek_length = 0
|
|
29
39
|
|
|
@@ -120,6 +130,22 @@ class ThreespaceSerialComClass(ThreespaceComClass):
|
|
|
120
130
|
@property
|
|
121
131
|
def name(self) -> str:
|
|
122
132
|
return self.ser.port
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def suffix(self) -> str:
|
|
136
|
+
return self.pid_to_str(self.get_port_info().pid)
|
|
137
|
+
|
|
138
|
+
def get_port_info(self):
|
|
139
|
+
ports = serial.tools.list_ports.comports()
|
|
140
|
+
for port in ports:
|
|
141
|
+
if port.device == self.ser.port:
|
|
142
|
+
return port
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def is_threespace_port(port: ListPortInfo):
|
|
147
|
+
cls = ThreespaceSerialComClass
|
|
148
|
+
return port.vid == cls.VID and (port.pid & cls.PID_V3_MASK == cls.PID_V3_MASK or port.pid == cls.PID_BOOTLOADER)
|
|
123
149
|
|
|
124
150
|
#This is not part of the ThreespaceComClass interface, but is useful as a utility for those directly using the ThreespaceSerialComClass
|
|
125
151
|
@staticmethod
|
|
@@ -127,7 +153,7 @@ class ThreespaceSerialComClass(ThreespaceComClass):
|
|
|
127
153
|
cls = ThreespaceSerialComClass
|
|
128
154
|
ports = serial.tools.list_ports.comports()
|
|
129
155
|
for port in ports:
|
|
130
|
-
if
|
|
156
|
+
if cls.is_threespace_port(port):
|
|
131
157
|
yield port
|
|
132
158
|
|
|
133
159
|
@staticmethod
|
|
@@ -139,7 +165,12 @@ class ThreespaceSerialComClass(ThreespaceComClass):
|
|
|
139
165
|
cls = ThreespaceSerialComClass
|
|
140
166
|
ports = serial.tools.list_ports.comports()
|
|
141
167
|
for port in ports:
|
|
142
|
-
if
|
|
168
|
+
if cls.is_threespace_port(port):
|
|
143
169
|
ser = serial.Serial(None, baudrate=default_baudrate, timeout=default_timeout) #By setting port as None, can create an object without immediately opening the port
|
|
144
170
|
ser.port = port.device #Now assign the port, allowing the serial object to exist without being opened yet
|
|
145
|
-
yield ThreespaceSerialComClass(ser)
|
|
171
|
+
yield ThreespaceSerialComClass(ser)
|
|
172
|
+
|
|
173
|
+
@classmethod
|
|
174
|
+
def pid_to_str(cls, pid):
|
|
175
|
+
if pid not in cls.PID_TO_STR_DICT: return "Unknown"
|
|
176
|
+
return cls.PID_TO_STR_DICT[pid]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from yostlabs.communication.base import *
|
|
2
|
+
import socket
|
|
3
|
+
import time
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
class ThreespaceSocketComClass(ThreespaceComClass):
|
|
7
|
+
|
|
8
|
+
"""
|
|
9
|
+
Inheriting classes must implement auto_detect() functionality.
|
|
10
|
+
Should also implement 'name' functionality
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, sock: socket.socket, *connection_params, connection_timeout=None):
|
|
14
|
+
self.socket = sock
|
|
15
|
+
self._opened = False
|
|
16
|
+
|
|
17
|
+
self.buffer = bytearray()
|
|
18
|
+
self.__timeout = 2
|
|
19
|
+
self.__connection_timeout = connection_timeout
|
|
20
|
+
self.__connection_params = connection_params
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def timeout(self) -> float:
|
|
24
|
+
return self.__timeout
|
|
25
|
+
|
|
26
|
+
@timeout.setter
|
|
27
|
+
def timeout(self, timeout: float):
|
|
28
|
+
self.__timeout = timeout
|
|
29
|
+
|
|
30
|
+
def open(self):
|
|
31
|
+
if self._opened: return True
|
|
32
|
+
try:
|
|
33
|
+
if self.__connection_timeout is None:
|
|
34
|
+
self.socket.setblocking(True)
|
|
35
|
+
else:
|
|
36
|
+
self.socket.settimeout(self.__connection_timeout)
|
|
37
|
+
self.socket.connect(*self.__connection_params)
|
|
38
|
+
except Exception as e:
|
|
39
|
+
try:
|
|
40
|
+
self.socket.close()
|
|
41
|
+
except: pass
|
|
42
|
+
print(e)
|
|
43
|
+
return False
|
|
44
|
+
self._opened = True
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
def close(self):
|
|
48
|
+
if not self._opened: return
|
|
49
|
+
self.socket.close()
|
|
50
|
+
self._opened = False
|
|
51
|
+
|
|
52
|
+
def check_open(self):
|
|
53
|
+
return self._opened
|
|
54
|
+
|
|
55
|
+
def write(self, bytes: bytes):
|
|
56
|
+
self.socket.send(bytes)
|
|
57
|
+
|
|
58
|
+
def read(self, num_bytes: int):
|
|
59
|
+
self.__update_while(lambda: len(self.buffer) < num_bytes)
|
|
60
|
+
amount = min(len(self.buffer), num_bytes)
|
|
61
|
+
result = self.buffer[:amount]
|
|
62
|
+
del self.buffer[:amount]
|
|
63
|
+
return result
|
|
64
|
+
|
|
65
|
+
def peek(self, num_bytes: int):
|
|
66
|
+
self.__update_while(lambda: len(self.buffer) < num_bytes)
|
|
67
|
+
amount = min(len(self.buffer), num_bytes)
|
|
68
|
+
return self.buffer[:amount]
|
|
69
|
+
|
|
70
|
+
def read_until(self, expected: bytes):
|
|
71
|
+
self.__update_while(lambda: expected not in self.buffer)
|
|
72
|
+
if expected in self.buffer:
|
|
73
|
+
length = self.buffer.index(expected) + len(expected)
|
|
74
|
+
result = self.buffer[:length]
|
|
75
|
+
del self.buffer[:length]
|
|
76
|
+
else:
|
|
77
|
+
result = self.buffer.copy()
|
|
78
|
+
self.buffer.clear()
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
def peek_until(self, expected: bytes, max_length: int = None):
|
|
82
|
+
self.__update_while(lambda: expected not in self.buffer and (max_length is None or len(self.buffer) < max_length))
|
|
83
|
+
if expected in self.buffer:
|
|
84
|
+
length = self.buffer.index(expected) + len(expected)
|
|
85
|
+
if max_length is not None:
|
|
86
|
+
length = min(length, max_length)
|
|
87
|
+
result = self.buffer[:length]
|
|
88
|
+
else:
|
|
89
|
+
length = len(self.buffer)
|
|
90
|
+
if max_length is not None:
|
|
91
|
+
length = min(length, max_length)
|
|
92
|
+
result = self.buffer[length]
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
def __update_while(self, condition: Callable):
|
|
96
|
+
start_time = time.perf_counter()
|
|
97
|
+
while condition():
|
|
98
|
+
self.__update_buffer()
|
|
99
|
+
if time.perf_counter() - start_time >= self.timeout:
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
def __update_buffer(self, max_bytes: int = 1000):
|
|
103
|
+
self.socket.setblocking(False)
|
|
104
|
+
try:
|
|
105
|
+
self.buffer += self.socket.recv(max_bytes)
|
|
106
|
+
except BlockingIOError:
|
|
107
|
+
return False
|
|
108
|
+
self.socket.settimeout(self.timeout)
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def length(self):
|
|
113
|
+
while self.__update_buffer(): pass
|
|
114
|
+
return len(self.buffer)
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def reenumerates(self) -> bool:
|
|
118
|
+
return False
|
|
@@ -421,6 +421,8 @@ class StreamableCommands(Enum):
|
|
|
421
421
|
GetGpsHdop = 218
|
|
422
422
|
GetGpsSattelites = 219
|
|
423
423
|
|
|
424
|
+
GetLedColor = 238
|
|
425
|
+
|
|
424
426
|
GetButtonState = 250
|
|
425
427
|
|
|
426
428
|
THREESPACE_AWAIT_COMMAND_FOUND = 0
|
|
@@ -1674,7 +1676,8 @@ class ThreespaceSensor:
|
|
|
1674
1676
|
def getStreamingLabel(self, cmd_num: int) -> ThreespaceCmdResult[str]: ...
|
|
1675
1677
|
def setCursor(self, cursor_index: int) -> ThreespaceCmdResult[None]: ...
|
|
1676
1678
|
def getLastLogCursorInfo(self) -> ThreespaceCmdResult[tuple[int,str]]: ...
|
|
1677
|
-
def pauseLogStreaming(self, pause: bool) -> ThreespaceCmdResult[None]: ...
|
|
1679
|
+
def pauseLogStreaming(self, pause: bool) -> ThreespaceCmdResult[None]: ...
|
|
1680
|
+
def getLedColor(self) -> ThreespaceCmdResult[list[float]]: ...
|
|
1678
1681
|
|
|
1679
1682
|
THREESPACE_GET_STREAMING_BATCH_COMMAND_NUM = 84
|
|
1680
1683
|
THREESPACE_START_STREAMING_COMMAND_NUM = 85
|
|
@@ -1817,6 +1820,8 @@ _threespace_commands: list[ThreespaceCommand] = [
|
|
|
1817
1820
|
ThreespaceCommand("softwareReset", THREESPACE_SOFTWARE_RESET_COMMAND_NUM, "", "", custom_func=ThreespaceSensor._ThreespaceSensor__softwareReset),
|
|
1818
1821
|
ThreespaceCommand("enterBootloader", THREESPACE_ENTER_BOOTLOADER_COMMAND_NUM, "", "", custom_func=ThreespaceSensor._ThreespaceSensor__enterBootloader),
|
|
1819
1822
|
|
|
1823
|
+
ThreespaceCommand("getLedColor", 238, "", "fff"),
|
|
1824
|
+
|
|
1820
1825
|
ThreespaceCommand("getButtonState", 250, "", "b"),
|
|
1821
1826
|
]
|
|
1822
1827
|
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|