pib-sdk 0.5__py3-none-any.whl
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.
- pib_sdk/__init__.py +0 -0
- pib_sdk/control.py +573 -0
- pib_sdk/kinematics.py +123 -0
- pib_sdk/pib_DH.py +95 -0
- pib_sdk-0.5.dist-info/METADATA +713 -0
- pib_sdk-0.5.dist-info/RECORD +8 -0
- pib_sdk-0.5.dist-info/WHEEL +5 -0
- pib_sdk-0.5.dist-info/top_level.txt +1 -0
pib_sdk/__init__.py
ADDED
|
File without changes
|
pib_sdk/control.py
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Optional, Any, Dict, Callable, Iterable, Tuple, List
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import argparse
|
|
8
|
+
import roslibpy
|
|
9
|
+
|
|
10
|
+
# ----------------------------- Logging setup -----------------------------
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger("pib.control")
|
|
13
|
+
handler = logging.StreamHandler()
|
|
14
|
+
formatter = logging.Formatter("[%(levelname)s] %(asctime)s %(name)s: %(message)s")
|
|
15
|
+
handler.setFormatter(formatter)
|
|
16
|
+
if not log.handlers:
|
|
17
|
+
log.addHandler(handler)
|
|
18
|
+
log.setLevel(logging.INFO)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ----------------------------- Conversions -------------------------------
|
|
22
|
+
|
|
23
|
+
def _as_joint_trajectory_dict(motor_name: str, position_int: int) -> Dict[str, Any]:
|
|
24
|
+
"""
|
|
25
|
+
trajectory_msgs/JointTrajectory represented as a rosbridge JSON dict.
|
|
26
|
+
"""
|
|
27
|
+
return {
|
|
28
|
+
"joint_names": [motor_name],
|
|
29
|
+
"points": [{"positions": [int(position_int)]}],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _deg_to_internal(position_deg: float) -> int:
|
|
34
|
+
"""
|
|
35
|
+
Map degrees (−90..90) to internal units ×100 (−9000..9000).
|
|
36
|
+
"""
|
|
37
|
+
if not -90.0 <= float(position_deg) <= 90.0:
|
|
38
|
+
raise ValueError(f"position_deg must be between -90 and 90 (got {position_deg})")
|
|
39
|
+
return int(round(float(position_deg) * 100))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _internal_to_deg(position: Optional[float]) -> Optional[float]:
|
|
43
|
+
if position is None:
|
|
44
|
+
return None
|
|
45
|
+
try:
|
|
46
|
+
return float(position) / 100.0
|
|
47
|
+
except Exception:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ----------------------------- Writers -----------------------------------
|
|
52
|
+
|
|
53
|
+
class Write:
|
|
54
|
+
"""
|
|
55
|
+
Publish motor updates through ROSBridge:
|
|
56
|
+
- Positions -> /joint_trajectory (trajectory_msgs/JointTrajectory)
|
|
57
|
+
- Other settings -> /motor_settings (datatypes/MotorSettings)
|
|
58
|
+
|
|
59
|
+
Diagnostics:
|
|
60
|
+
- debug logging
|
|
61
|
+
- send_with_ack() waits until your message is observed on the bus
|
|
62
|
+
- health_check() verifies rosbridge and required topics/types
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
host: str = "localhost",
|
|
68
|
+
port: int = 9090,
|
|
69
|
+
jt_topic_name: str = "/joint_trajectory",
|
|
70
|
+
jt_message_type: str = "trajectory_msgs/JointTrajectory",
|
|
71
|
+
ms_topic_name: str = "/motor_settings",
|
|
72
|
+
ms_message_type: str = "datatypes/MotorSettings",
|
|
73
|
+
debug: bool = False,
|
|
74
|
+
):
|
|
75
|
+
if debug:
|
|
76
|
+
log.setLevel(logging.DEBUG)
|
|
77
|
+
|
|
78
|
+
self.host = host
|
|
79
|
+
self.port = port
|
|
80
|
+
self.jt_topic_name = jt_topic_name
|
|
81
|
+
self.jt_message_type = jt_message_type
|
|
82
|
+
self.ms_topic_name = ms_topic_name
|
|
83
|
+
self.ms_message_type = ms_message_type
|
|
84
|
+
|
|
85
|
+
# Connect to ROSBridge (websocket)
|
|
86
|
+
self.ros = roslibpy.Ros(host=host, port=port)
|
|
87
|
+
|
|
88
|
+
# Register event hooks for visibility
|
|
89
|
+
try:
|
|
90
|
+
self.ros.on_ready(lambda: log.info("ROSBridge connection established"))
|
|
91
|
+
self.ros.on_close(lambda: log.warning("ROSBridge connection closed"))
|
|
92
|
+
self.ros.on_error(lambda err: log.error(f"ROSBridge error: {err}"))
|
|
93
|
+
except Exception:
|
|
94
|
+
# Older roslibpy might not support the on_* helpers
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
log.debug("Connecting to ROSBridge...")
|
|
98
|
+
self.ros.run()
|
|
99
|
+
log.debug("Connected: %s", self.ros.is_connected)
|
|
100
|
+
|
|
101
|
+
# Publishers
|
|
102
|
+
self._jt_topic = roslibpy.Topic(self.ros, jt_topic_name, jt_message_type)
|
|
103
|
+
self._ms_topic = roslibpy.Topic(self.ros, ms_topic_name, ms_message_type)
|
|
104
|
+
|
|
105
|
+
log.debug("Advertised publishers: %s, %s", jt_topic_name, ms_topic_name)
|
|
106
|
+
|
|
107
|
+
# ------------------ Diagnostics / health ------------------
|
|
108
|
+
|
|
109
|
+
def health_check(self, timeout: float = 3.0) -> Dict[str, Any]:
|
|
110
|
+
"""
|
|
111
|
+
Confirms connectivity and that required topics exist with the expected types,
|
|
112
|
+
using rosapi services (requires rosbridge_server + rosapi).
|
|
113
|
+
"""
|
|
114
|
+
if not self.ros.is_connected:
|
|
115
|
+
raise ConnectionError("Not connected to ROSBridge")
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
svc = roslibpy.Service(self.ros, "/rosapi/topics", "rosapi/Topics")
|
|
119
|
+
req = roslibpy.ServiceRequest()
|
|
120
|
+
done = threading.Event()
|
|
121
|
+
out: Dict[str, Any] = {}
|
|
122
|
+
|
|
123
|
+
def _cb(resp: Dict[str, Any]):
|
|
124
|
+
out.update(resp or {})
|
|
125
|
+
done.set()
|
|
126
|
+
|
|
127
|
+
svc.call(req, callback=_cb, errback=lambda e: done.set())
|
|
128
|
+
ok = done.wait(timeout)
|
|
129
|
+
if not ok:
|
|
130
|
+
raise TimeoutError("Timed out calling /rosapi/topics")
|
|
131
|
+
|
|
132
|
+
topics: List[str] = out.get("topics", [])
|
|
133
|
+
types: List[str] = out.get("types", [])
|
|
134
|
+
tm = dict(zip(topics, types))
|
|
135
|
+
|
|
136
|
+
result = {
|
|
137
|
+
"connected": self.ros.is_connected,
|
|
138
|
+
"topics_present": {
|
|
139
|
+
self.jt_topic_name: tm.get(self.jt_topic_name),
|
|
140
|
+
self.ms_topic_name: tm.get(self.ms_topic_name),
|
|
141
|
+
},
|
|
142
|
+
"types_ok": (
|
|
143
|
+
tm.get(self.jt_topic_name) == self.jt_message_type
|
|
144
|
+
and tm.get(self.ms_topic_name) == self.ms_message_type
|
|
145
|
+
),
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
log.info("Health: %s", json.dumps(result, indent=2))
|
|
149
|
+
return result
|
|
150
|
+
except Exception as e:
|
|
151
|
+
log.error("Health check failed: %s", e)
|
|
152
|
+
raise
|
|
153
|
+
|
|
154
|
+
# ------------------ Publish APIs ------------------
|
|
155
|
+
|
|
156
|
+
def send(
|
|
157
|
+
self,
|
|
158
|
+
motor_name: str,
|
|
159
|
+
*,
|
|
160
|
+
position_deg: Optional[float] = None,
|
|
161
|
+
velocity: Optional[int] = None,
|
|
162
|
+
acceleration: Optional[int] = None,
|
|
163
|
+
deceleration: Optional[int] = None,
|
|
164
|
+
turned_on: Optional[bool] = None,
|
|
165
|
+
pulse_width_min: Optional[int] = None,
|
|
166
|
+
pulse_width_max: Optional[int] = None,
|
|
167
|
+
rotation_range_min: Optional[int] = None,
|
|
168
|
+
rotation_range_max: Optional[int] = None,
|
|
169
|
+
period: Optional[int] = None,
|
|
170
|
+
visible: Optional[bool] = None,
|
|
171
|
+
invert: Optional[bool] = None,
|
|
172
|
+
extra_ms_fields: Optional[Dict[str, Any]] = None,
|
|
173
|
+
**kwargs: Any,
|
|
174
|
+
) -> None:
|
|
175
|
+
"""
|
|
176
|
+
- If position is provided, publish it via /joint_trajectory (only).
|
|
177
|
+
- All other non-None settings are sent via /motor_settings.
|
|
178
|
+
"""
|
|
179
|
+
# 1) Position via JointTrajectory
|
|
180
|
+
if position_deg is not None:
|
|
181
|
+
pos_int = _deg_to_internal(position_deg)
|
|
182
|
+
jt_msg = _as_joint_trajectory_dict(motor_name, pos_int)
|
|
183
|
+
log.debug("Publishing JT: %s", jt_msg)
|
|
184
|
+
self._jt_topic.publish(roslibpy.Message(jt_msg))
|
|
185
|
+
log.info("JT published -> %s: position=%s (int=%s)", motor_name, position_deg, pos_int)
|
|
186
|
+
|
|
187
|
+
# 2) Remaining settings via MotorSettings (exclude position entirely)
|
|
188
|
+
ms_payload: Dict[str, Any] = {"motor_name": motor_name}
|
|
189
|
+
maybe = dict(
|
|
190
|
+
velocity=velocity,
|
|
191
|
+
acceleration=acceleration,
|
|
192
|
+
deceleration=deceleration,
|
|
193
|
+
turned_on=turned_on,
|
|
194
|
+
pulse_width_min=pulse_width_min,
|
|
195
|
+
pulse_width_max=pulse_width_max,
|
|
196
|
+
rotation_range_min=rotation_range_min,
|
|
197
|
+
rotation_range_max=rotation_range_max,
|
|
198
|
+
period=period,
|
|
199
|
+
visible=visible,
|
|
200
|
+
invert=invert,
|
|
201
|
+
)
|
|
202
|
+
for k, v in maybe.items():
|
|
203
|
+
if v is not None:
|
|
204
|
+
ms_payload[k] = v
|
|
205
|
+
|
|
206
|
+
if extra_ms_fields:
|
|
207
|
+
ms_payload.update({k: v for k, v in extra_ms_fields.items() if k != "position"})
|
|
208
|
+
if kwargs:
|
|
209
|
+
for k, v in kwargs.items():
|
|
210
|
+
if k != "position" and v is not None:
|
|
211
|
+
ms_payload[k] = v
|
|
212
|
+
|
|
213
|
+
if any(k for k in ms_payload.keys() if k != "motor_name"):
|
|
214
|
+
log.debug("Publishing MS: %s", ms_payload)
|
|
215
|
+
self._ms_topic.publish(roslibpy.Message(ms_payload))
|
|
216
|
+
log.info("MS published -> %s: fields=%s", motor_name, [k for k in ms_payload if k != "motor_name"])
|
|
217
|
+
|
|
218
|
+
def send_with_ack(
|
|
219
|
+
self,
|
|
220
|
+
motor_name: str,
|
|
221
|
+
*,
|
|
222
|
+
position_deg: Optional[float] = None,
|
|
223
|
+
ack_timeout: float = 2.5,
|
|
224
|
+
observe_ms: bool = False,
|
|
225
|
+
**settings: Any,
|
|
226
|
+
) -> bool:
|
|
227
|
+
"""
|
|
228
|
+
Publish (like send) then wait to *observe* the matching message on the bus.
|
|
229
|
+
Returns True if observed within timeout, else False.
|
|
230
|
+
- For position: listens on /joint_trajectory for the motor name + position.
|
|
231
|
+
- If observe_ms=True: also waits for a /motor_settings update for that motor.
|
|
232
|
+
"""
|
|
233
|
+
evt_jt = threading.Event()
|
|
234
|
+
evt_ms = threading.Event() if observe_ms else None
|
|
235
|
+
|
|
236
|
+
jt_expected_int = None
|
|
237
|
+
if position_deg is not None:
|
|
238
|
+
jt_expected_int = _deg_to_internal(position_deg)
|
|
239
|
+
|
|
240
|
+
# Subscribe BEFORE publish to avoid missing fast round-trips
|
|
241
|
+
def _on_jt(msg: Dict[str, Any]):
|
|
242
|
+
try:
|
|
243
|
+
names = msg.get("joint_names") or []
|
|
244
|
+
pts = msg.get("points") or []
|
|
245
|
+
if not names or not pts:
|
|
246
|
+
return
|
|
247
|
+
name = names[0]
|
|
248
|
+
positions = pts[0].get("positions") or []
|
|
249
|
+
if not positions:
|
|
250
|
+
return
|
|
251
|
+
pos = int(positions[0])
|
|
252
|
+
if name == motor_name and (jt_expected_int is None or pos == jt_expected_int):
|
|
253
|
+
log.debug("Observed JT echo for %s: %s", motor_name, msg)
|
|
254
|
+
evt_jt.set()
|
|
255
|
+
except Exception:
|
|
256
|
+
pass
|
|
257
|
+
|
|
258
|
+
self._jt_topic.subscribe(_on_jt)
|
|
259
|
+
|
|
260
|
+
def _on_ms(msg: Dict[str, Any]):
|
|
261
|
+
try:
|
|
262
|
+
if msg.get("motor_name") == motor_name:
|
|
263
|
+
log.debug("Observed MS echo for %s: %s", motor_name, msg)
|
|
264
|
+
if evt_ms:
|
|
265
|
+
evt_ms.set()
|
|
266
|
+
except Exception:
|
|
267
|
+
pass
|
|
268
|
+
|
|
269
|
+
if evt_ms is not None:
|
|
270
|
+
self._ms_topic.subscribe(_on_ms)
|
|
271
|
+
|
|
272
|
+
# Publish
|
|
273
|
+
try:
|
|
274
|
+
self.send(motor_name, position_deg=position_deg, **settings)
|
|
275
|
+
except Exception as e:
|
|
276
|
+
log.error("Publish failed: %s", e)
|
|
277
|
+
# Unsubscribe before raising
|
|
278
|
+
try:
|
|
279
|
+
self._jt_topic.unsubscribe(_on_jt)
|
|
280
|
+
except Exception:
|
|
281
|
+
pass
|
|
282
|
+
if evt_ms is not None:
|
|
283
|
+
try:
|
|
284
|
+
self._ms_topic.unsubscribe(_on_ms)
|
|
285
|
+
except Exception:
|
|
286
|
+
pass
|
|
287
|
+
raise
|
|
288
|
+
|
|
289
|
+
# Wait
|
|
290
|
+
ok_jt = evt_jt.wait(ack_timeout) if jt_expected_int is not None else True
|
|
291
|
+
ok_ms = evt_ms.wait(ack_timeout) if evt_ms is not None else True
|
|
292
|
+
|
|
293
|
+
# Cleanup subscriptions
|
|
294
|
+
try:
|
|
295
|
+
self._jt_topic.unsubscribe(_on_jt)
|
|
296
|
+
except Exception:
|
|
297
|
+
pass
|
|
298
|
+
if evt_ms is not None:
|
|
299
|
+
try:
|
|
300
|
+
self._ms_topic.unsubscribe(_on_ms)
|
|
301
|
+
except Exception:
|
|
302
|
+
pass
|
|
303
|
+
|
|
304
|
+
if ok_jt and ok_ms:
|
|
305
|
+
log.info("ACK OK for %s (JT%s%s)", motor_name, "" if jt_expected_int is None else f"={jt_expected_int}",
|
|
306
|
+
" + MS" if observe_ms else "")
|
|
307
|
+
return True
|
|
308
|
+
else:
|
|
309
|
+
if not ok_jt:
|
|
310
|
+
log.warning("ACK TIMEOUT: Did not observe JointTrajectory for %s within %.2fs", motor_name, ack_timeout)
|
|
311
|
+
if evt_ms is not None and not ok_ms:
|
|
312
|
+
log.warning("ACK TIMEOUT: Did not observe MotorSettings for %s within %.2fs", motor_name, ack_timeout)
|
|
313
|
+
return False
|
|
314
|
+
|
|
315
|
+
def close(self) -> None:
|
|
316
|
+
# Cleanly close
|
|
317
|
+
try:
|
|
318
|
+
self._jt_topic.unadvertise()
|
|
319
|
+
except Exception:
|
|
320
|
+
pass
|
|
321
|
+
try:
|
|
322
|
+
self._ms_topic.unadvertise()
|
|
323
|
+
except Exception:
|
|
324
|
+
pass
|
|
325
|
+
try:
|
|
326
|
+
self.ros.terminate()
|
|
327
|
+
except Exception:
|
|
328
|
+
pass
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def write(
|
|
332
|
+
motor_name: str,
|
|
333
|
+
*,
|
|
334
|
+
position_deg: Optional[float] = None,
|
|
335
|
+
host: str = "localhost",
|
|
336
|
+
port: int = 9090,
|
|
337
|
+
**settings: Any,
|
|
338
|
+
) -> None:
|
|
339
|
+
"""
|
|
340
|
+
One-shot helper: sends position (if provided) to /joint_trajectory,
|
|
341
|
+
and other settings to /motor_settings.
|
|
342
|
+
"""
|
|
343
|
+
w = Write(host=host, port=port)
|
|
344
|
+
try:
|
|
345
|
+
w.send(motor_name=motor_name, position_deg=position_deg, **settings)
|
|
346
|
+
finally:
|
|
347
|
+
w.close()
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# ----------------------------- Readers -----------------------------------
|
|
351
|
+
|
|
352
|
+
class Read:
|
|
353
|
+
"""
|
|
354
|
+
Subscribe to both /joint_trajectory and /motor_settings.
|
|
355
|
+
Your callback receives a merged dict per motor update:
|
|
356
|
+
|
|
357
|
+
{
|
|
358
|
+
'motor_name': 'joint1',
|
|
359
|
+
'position': 1234, # from JointTrajectory (if available)
|
|
360
|
+
'position_deg': 12.34, # convenience
|
|
361
|
+
'velocity': 100, ... # from MotorSettings (if provided)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
Diagnostics: set debug=True to get verbose logs as messages arrive.
|
|
365
|
+
"""
|
|
366
|
+
|
|
367
|
+
def __init__(
|
|
368
|
+
self,
|
|
369
|
+
callback: Callable[[Dict[str, Any]], None],
|
|
370
|
+
host: str = "localhost",
|
|
371
|
+
port: int = 9090,
|
|
372
|
+
jt_topic_name: str = "/joint_trajectory",
|
|
373
|
+
jt_message_type: str = "trajectory_msgs/JointTrajectory",
|
|
374
|
+
ms_topic_name: str = "/motor_settings",
|
|
375
|
+
ms_message_type: str = "datatypes/MotorSettings",
|
|
376
|
+
debug: bool = False,
|
|
377
|
+
):
|
|
378
|
+
if debug:
|
|
379
|
+
log.setLevel(logging.DEBUG)
|
|
380
|
+
|
|
381
|
+
self._cb = callback
|
|
382
|
+
self._cache: Dict[str, Dict[str, Any]] = {}
|
|
383
|
+
|
|
384
|
+
# Connect
|
|
385
|
+
self.ros = roslibpy.Ros(host=host, port=port)
|
|
386
|
+
self.ros.run()
|
|
387
|
+
|
|
388
|
+
# Topics
|
|
389
|
+
self._jt = roslibpy.Topic(self.ros, jt_topic_name, jt_message_type)
|
|
390
|
+
self._ms = roslibpy.Topic(self.ros, ms_topic_name, ms_message_type)
|
|
391
|
+
|
|
392
|
+
# Subscriptions
|
|
393
|
+
self._jt.subscribe(self._on_jt)
|
|
394
|
+
self._ms.subscribe(self._on_ms)
|
|
395
|
+
|
|
396
|
+
log.debug("Read subscribed to %s and %s", jt_topic_name, ms_topic_name)
|
|
397
|
+
|
|
398
|
+
# ---- internal handlers ----
|
|
399
|
+
|
|
400
|
+
def _emit(self, motor_name: str) -> None:
|
|
401
|
+
data = dict(self._cache.get(motor_name, {}))
|
|
402
|
+
if "position" in data:
|
|
403
|
+
data["position_deg"] = _internal_to_deg(data.get("position"))
|
|
404
|
+
log.debug("Emit merged update: %s", data)
|
|
405
|
+
self._cb(data)
|
|
406
|
+
|
|
407
|
+
def _on_jt(self, msg: Dict[str, Any]) -> None:
|
|
408
|
+
names = msg.get("joint_names") or []
|
|
409
|
+
points = msg.get("points") or []
|
|
410
|
+
if not names or not points:
|
|
411
|
+
return
|
|
412
|
+
motor_name = names[0]
|
|
413
|
+
positions = points[0].get("positions") or []
|
|
414
|
+
if not positions:
|
|
415
|
+
return
|
|
416
|
+
pos = int(positions[0])
|
|
417
|
+
|
|
418
|
+
entry = self._cache.setdefault(motor_name, {"motor_name": motor_name})
|
|
419
|
+
entry["position"] = pos
|
|
420
|
+
self._emit(motor_name)
|
|
421
|
+
|
|
422
|
+
def _on_ms(self, msg: Dict[str, Any]) -> None:
|
|
423
|
+
motor_name = msg.get("motor_name")
|
|
424
|
+
if not motor_name:
|
|
425
|
+
return
|
|
426
|
+
entry = self._cache.setdefault(motor_name, {"motor_name": motor_name})
|
|
427
|
+
for k, v in msg.items():
|
|
428
|
+
if k in ("motor_name", "position"):
|
|
429
|
+
continue # ignore 'position' here; position comes from JointTrajectory
|
|
430
|
+
entry[k] = v
|
|
431
|
+
self._emit(motor_name)
|
|
432
|
+
|
|
433
|
+
def close(self) -> None:
|
|
434
|
+
try:
|
|
435
|
+
self._jt.unsubscribe(self._on_jt)
|
|
436
|
+
except Exception:
|
|
437
|
+
pass
|
|
438
|
+
try:
|
|
439
|
+
self._ms.unsubscribe(self._on_ms)
|
|
440
|
+
except Exception:
|
|
441
|
+
pass
|
|
442
|
+
try:
|
|
443
|
+
self.ros.terminate()
|
|
444
|
+
except Exception:
|
|
445
|
+
pass
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def read_stream(
|
|
449
|
+
callback: Callable[[Dict[str, Any]], None],
|
|
450
|
+
*,
|
|
451
|
+
host: str = "localhost",
|
|
452
|
+
port: int = 9090,
|
|
453
|
+
debug: bool = False,
|
|
454
|
+
) -> Read:
|
|
455
|
+
"""
|
|
456
|
+
Continuous merged stream from /joint_trajectory + /motor_settings.
|
|
457
|
+
"""
|
|
458
|
+
return Read(callback, host=host, port=port, debug=debug)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def read(
|
|
462
|
+
motor_name: str,
|
|
463
|
+
*,
|
|
464
|
+
host: str = "localhost",
|
|
465
|
+
port: int = 9090,
|
|
466
|
+
timeout: float = 3.0,
|
|
467
|
+
debug: bool = False,
|
|
468
|
+
) -> Dict[str, Any]:
|
|
469
|
+
"""
|
|
470
|
+
One-shot blocking read: waits for either a JointTrajectory or MotorSettings
|
|
471
|
+
update for the given motor and returns the latest merged view (within timeout).
|
|
472
|
+
"""
|
|
473
|
+
if debug:
|
|
474
|
+
log.setLevel(logging.DEBUG)
|
|
475
|
+
|
|
476
|
+
result: Dict[str, Any] = {}
|
|
477
|
+
evt = threading.Event()
|
|
478
|
+
|
|
479
|
+
def _cb(data: Dict[str, Any]) -> None:
|
|
480
|
+
if data.get("motor_name") == motor_name:
|
|
481
|
+
result.clear()
|
|
482
|
+
result.update(data)
|
|
483
|
+
evt.set()
|
|
484
|
+
|
|
485
|
+
r = Read(_cb, host=host, port=port, debug=debug)
|
|
486
|
+
ok = evt.wait(timeout)
|
|
487
|
+
r.close()
|
|
488
|
+
|
|
489
|
+
if not ok:
|
|
490
|
+
raise TimeoutError(f"No update for '{motor_name}' within {timeout} seconds")
|
|
491
|
+
return result
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
# ----------------------------- CLI / Demos --------------------------------
|
|
495
|
+
|
|
496
|
+
def _cli_check(args: argparse.Namespace) -> int:
|
|
497
|
+
w = Write(host=args.host, port=args.port, debug=args.debug)
|
|
498
|
+
try:
|
|
499
|
+
w.health_check(timeout=args.timeout)
|
|
500
|
+
return 0
|
|
501
|
+
finally:
|
|
502
|
+
w.close()
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _cli_send(args: argparse.Namespace) -> int:
|
|
506
|
+
w = Write(host=args.host, port=args.port, debug=args.debug)
|
|
507
|
+
try:
|
|
508
|
+
if args.ack:
|
|
509
|
+
ok = w.send_with_ack(
|
|
510
|
+
args.motor,
|
|
511
|
+
position_deg=args.position_deg,
|
|
512
|
+
ack_timeout=args.timeout,
|
|
513
|
+
observe_ms=args.observe_ms,
|
|
514
|
+
)
|
|
515
|
+
print("ACK:", "OK" if ok else "TIMEOUT")
|
|
516
|
+
return 0 if ok else 2
|
|
517
|
+
else:
|
|
518
|
+
w.send(args.motor, position_deg=args.position_deg)
|
|
519
|
+
return 0
|
|
520
|
+
finally:
|
|
521
|
+
w.close()
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _cli_echo(args: argparse.Namespace) -> int:
|
|
525
|
+
def _cb(d: Dict[str, Any]):
|
|
526
|
+
if (args.motor is None) or (d.get("motor_name") == args.motor):
|
|
527
|
+
print(json.dumps(d, ensure_ascii=False))
|
|
528
|
+
r = Read(_cb, host=args.host, port=args.port, debug=args.debug)
|
|
529
|
+
try:
|
|
530
|
+
while True:
|
|
531
|
+
time.sleep(0.2)
|
|
532
|
+
except KeyboardInterrupt:
|
|
533
|
+
pass
|
|
534
|
+
finally:
|
|
535
|
+
r.close()
|
|
536
|
+
return 0
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
540
|
+
p = argparse.ArgumentParser(description="pib control SDK over rosbridge")
|
|
541
|
+
p.add_argument("--host", default="localhost")
|
|
542
|
+
p.add_argument("--port", type=int, default=9090)
|
|
543
|
+
p.add_argument("--debug", action="store_true", help="enable verbose logging")
|
|
544
|
+
|
|
545
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
546
|
+
|
|
547
|
+
p_check = sub.add_parser("check", help="verify connection & topics via rosapi")
|
|
548
|
+
p_check.add_argument("--timeout", type=float, default=3.0)
|
|
549
|
+
p_check.set_defaults(func=_cli_check)
|
|
550
|
+
|
|
551
|
+
p_send = sub.add_parser("send", help="send a single position (deg)")
|
|
552
|
+
p_send.add_argument("--motor", required=True, help="motor/joint name")
|
|
553
|
+
p_send.add_argument("--position-deg", type=float, required=False, default=None)
|
|
554
|
+
p_send.add_argument("--ack", action="store_true", help="wait to observe echo on the bus")
|
|
555
|
+
p_send.add_argument("--observe-ms", action="store_true", help="also wait for /motor_settings echo")
|
|
556
|
+
p_send.add_argument("--timeout", type=float, default=2.5)
|
|
557
|
+
p_send.set_defaults(func=_cli_send)
|
|
558
|
+
|
|
559
|
+
p_echo = sub.add_parser("echo", help="print merged messages")
|
|
560
|
+
p_echo.add_argument("--motor", required=False, help="filter by motor name")
|
|
561
|
+
p_echo.set_defaults(func=_cli_echo)
|
|
562
|
+
|
|
563
|
+
return p
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def main():
|
|
567
|
+
parser = _build_parser()
|
|
568
|
+
args = parser.parse_args()
|
|
569
|
+
return args.func(args)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
if __name__ == "__main__":
|
|
573
|
+
raise SystemExit(main())
|
pib_sdk/kinematics.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from importlib import import_module
|
|
2
|
+
from typing import Iterable, Literal, Sequence, Optional
|
|
3
|
+
import numpy as np
|
|
4
|
+
from spatialmath import SE3
|
|
5
|
+
from roboticstoolbox import DHRobot
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _to_rad(seq_deg: Sequence[float]) -> np.ndarray:
|
|
9
|
+
#Degrees → radians (returns NumPy array)
|
|
10
|
+
return np.deg2rad(np.asarray(seq_deg, dtype=float))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _to_deg(seq_rad: Sequence[float]) -> np.ndarray:
|
|
14
|
+
#Radians → degrees (returns NumPy array)
|
|
15
|
+
return np.rad2deg(np.asarray(seq_rad, dtype=float))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _get_robot(side: Literal["right", "left"]) -> DHRobot:
|
|
19
|
+
#Instantiate either pib_right() or pib_left() from DH_model.pib_DH.
|
|
20
|
+
mod = import_module("pib_sdk.pib_DH")
|
|
21
|
+
cls_name = {"right": "pib_right", "left": "pib_left"}[side.lower()]
|
|
22
|
+
return getattr(mod, cls_name)()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Forward kinematics
|
|
26
|
+
|
|
27
|
+
class FK:
|
|
28
|
+
def __init__(self, side: Literal["right", "left"] = "right"):
|
|
29
|
+
self.robot: DHRobot = _get_robot(side)
|
|
30
|
+
self.joint_names = [f"theta{i+1}" for i in range(self.robot.n)]
|
|
31
|
+
|
|
32
|
+
def pose(self, q_deg: Iterable[float]) -> SE3:
|
|
33
|
+
#Compute end-effector SE3 pose for a joint vector (degrees) raises ValueError if the length of `q_deg` is not equal to DOF.
|
|
34
|
+
q_deg = list(q_deg)
|
|
35
|
+
if len(q_deg) != self.robot.n:
|
|
36
|
+
raise ValueError(f"Expected {self.robot.n} joint values, got {len(q_deg)}")
|
|
37
|
+
return self.robot.fkine(_to_rad(q_deg))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Inverse kinematics
|
|
41
|
+
|
|
42
|
+
class IK:
|
|
43
|
+
def __init__(self, side: Literal["right", "left"] = "right"):
|
|
44
|
+
self.robot: DHRobot = _get_robot(side)
|
|
45
|
+
|
|
46
|
+
def solve(
|
|
47
|
+
self,
|
|
48
|
+
xyz: Sequence[float],
|
|
49
|
+
rpy_deg: Optional[Sequence[float]] = None,
|
|
50
|
+
q0_deg: Optional[Iterable[float]] = None,
|
|
51
|
+
tol: float = 1e-4,
|
|
52
|
+
max_steps: int = 100,
|
|
53
|
+
custom_mask: Optional[Sequence[float]] = None,
|
|
54
|
+
) -> np.ndarray:
|
|
55
|
+
"""
|
|
56
|
+
Return joint angles (degrees) for the requested pose.
|
|
57
|
+
|
|
58
|
+
Parameters
|
|
59
|
+
----------
|
|
60
|
+
xyz : (3,) sequence
|
|
61
|
+
Target position in millimetres.
|
|
62
|
+
rpy_deg : (3,) sequence, optional
|
|
63
|
+
Target orientation (roll, pitch, yaw in degrees). If ``None``,
|
|
64
|
+
orientation is ignored (position-only IK).
|
|
65
|
+
q0_deg : initial guess in degrees (defaults to robot.qz).
|
|
66
|
+
tol, max_steps : convergence settings passed to ikine_LM().
|
|
67
|
+
custom_mask : optional 6-element mask overriding the automatic one raises ValueError if the solver fails to converge.
|
|
68
|
+
"""
|
|
69
|
+
# Build target SE3
|
|
70
|
+
T = SE3(*xyz) if rpy_deg is None else SE3(*xyz) * SE3.RPY(*rpy_deg, unit="deg")
|
|
71
|
+
|
|
72
|
+
# Default mask
|
|
73
|
+
mask = [1, 1, 1, 0, 0, 0] if rpy_deg is None else [1, 1, 1, 1, 1, 1]
|
|
74
|
+
if custom_mask is not None:
|
|
75
|
+
if len(custom_mask) != 6:
|
|
76
|
+
raise ValueError("custom_mask must have 6 elements")
|
|
77
|
+
mask = list(custom_mask)
|
|
78
|
+
|
|
79
|
+
# Initial guess
|
|
80
|
+
q0 = self.robot.qz if q0_deg is None else _to_rad(q0_deg)
|
|
81
|
+
|
|
82
|
+
# Solve
|
|
83
|
+
sol = self.robot.ikine_LM(
|
|
84
|
+
T,
|
|
85
|
+
q0=q0,
|
|
86
|
+
tol=tol,
|
|
87
|
+
ilimit=max_steps,
|
|
88
|
+
mask=mask,
|
|
89
|
+
)
|
|
90
|
+
if not sol.success:
|
|
91
|
+
raise ValueError(f"IK failed: {sol.reason}")
|
|
92
|
+
return _to_deg(sol.q)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# One-liner convenience functions
|
|
97
|
+
|
|
98
|
+
def fk(side: Literal["right", "left"], q_deg: Iterable[float]) -> SE3:
|
|
99
|
+
"""
|
|
100
|
+
One-call forward kinematics.
|
|
101
|
+
|
|
102
|
+
Example
|
|
103
|
+
-------
|
|
104
|
+
>>> pose = fk("right", [0, 45, 0, 0, 90, 0])
|
|
105
|
+
"""
|
|
106
|
+
return FK(side).pose(q_deg)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def ik(
|
|
110
|
+
side: Literal["right", "left"],
|
|
111
|
+
*,
|
|
112
|
+
xyz: Sequence[float],
|
|
113
|
+
rpy_deg: Optional[Sequence[float]] = None,
|
|
114
|
+
**kw,
|
|
115
|
+
) -> np.ndarray:
|
|
116
|
+
"""
|
|
117
|
+
One-call inverse kinematics.
|
|
118
|
+
|
|
119
|
+
Example
|
|
120
|
+
-------
|
|
121
|
+
>>> q = ik("left", xyz=[150, 0, 350])
|
|
122
|
+
"""
|
|
123
|
+
return IK(side).solve(xyz=xyz, rpy_deg=rpy_deg, **kw)
|