microcmm 0.1.0__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.
- microcmm/__init__.py +17 -0
- microcmm/arm.py +229 -0
- microcmm/cli.py +288 -0
- microcmm/device.py +100 -0
- microcmm/hci.py +386 -0
- microcmm-0.1.0.dist-info/METADATA +88 -0
- microcmm-0.1.0.dist-info/RECORD +10 -0
- microcmm-0.1.0.dist-info/WHEEL +4 -0
- microcmm-0.1.0.dist-info/entry_points.txt +2 -0
- microcmm-0.1.0.dist-info/licenses/LICENSE +21 -0
microcmm/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .arm import Arm, NotHomedError, Pose, UnsupportedDeviceError
|
|
2
|
+
from .device import MicroScribe
|
|
3
|
+
from .hci import HCI, DeviceInfo, HCIError, HCITimeout, Maxes, Packet
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"HCI",
|
|
7
|
+
"Arm",
|
|
8
|
+
"DeviceInfo",
|
|
9
|
+
"HCIError",
|
|
10
|
+
"HCITimeout",
|
|
11
|
+
"Maxes",
|
|
12
|
+
"MicroScribe",
|
|
13
|
+
"NotHomedError",
|
|
14
|
+
"Packet",
|
|
15
|
+
"Pose",
|
|
16
|
+
"UnsupportedDeviceError",
|
|
17
|
+
]
|
microcmm/arm.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""forward kinematics for the microscribe arm.
|
|
2
|
+
|
|
3
|
+
transcribed from the 1996 immersion sdk's arm.c: the arm never computes xyz
|
|
4
|
+
itself -- it reports raw encoder counts, and the host fetches the
|
|
5
|
+
denavit-hartenberg parameters ("Format DH0.5") stored in the arm's eeprom
|
|
6
|
+
and does the kinematics. joint 2 has a beta-corrected matrix (a mechanism
|
|
7
|
+
in the standard sdk for all arms, not something unit-specific); beta is 0
|
|
8
|
+
unless the arm's comment string contains "Beta", in which case
|
|
9
|
+
GET_EXT_PARAMS supplies the value from eeprom.
|
|
10
|
+
|
|
11
|
+
units are millimeters throughout (the eeprom stores thousandths of an inch;
|
|
12
|
+
converted once at parameter load).
|
|
13
|
+
|
|
14
|
+
homing: this arm calibrates by being in its exact home pose (stylus seated
|
|
15
|
+
and vertical, counterweight pressed against the stylus holder) either at
|
|
16
|
+
power-on or when HOME_POS is sent. the device cannot report whether that
|
|
17
|
+
was done correctly, so this class refuses to compute positions until the
|
|
18
|
+
caller has either called home() (arm physically in the home pose) or
|
|
19
|
+
explicitly accepted responsibility via assume_homed().
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import math
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
|
|
27
|
+
from .hci import HCI, DeviceInfo, HCIError, Maxes, Packet
|
|
28
|
+
|
|
29
|
+
NUM_DOF = 6
|
|
30
|
+
MM_PER_INCH = 25.4
|
|
31
|
+
|
|
32
|
+
# the only hardware this library has ever been tested against.
|
|
33
|
+
TESTED_PRODUCTS = {("MicroScribe3D", "D")}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class NotHomedError(HCIError):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class UnsupportedDeviceError(HCIError):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Pose:
|
|
46
|
+
x: float
|
|
47
|
+
y: float
|
|
48
|
+
z: float
|
|
49
|
+
# stylus direction, XYZ_FIXED convention (arm_calc_stylus_dir), radians
|
|
50
|
+
roll: float
|
|
51
|
+
pitch: float
|
|
52
|
+
yaw: float
|
|
53
|
+
joints_rad: list[float]
|
|
54
|
+
buttons: int
|
|
55
|
+
encoders: list[int]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Arm:
|
|
59
|
+
"""wraps an HCI session with the arm's calibration constants. mm."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, hci: HCI):
|
|
62
|
+
self.hci = hci
|
|
63
|
+
self.info: DeviceInfo | None = None
|
|
64
|
+
self.maxes: Maxes | None = None
|
|
65
|
+
self.alpha = [0.0] * NUM_DOF
|
|
66
|
+
self.a = [0.0] * NUM_DOF
|
|
67
|
+
self.d = [0.0] * NUM_DOF
|
|
68
|
+
self.beta = 0.0
|
|
69
|
+
self.radians_factor = [0.0] * NUM_DOF
|
|
70
|
+
self._homed = False
|
|
71
|
+
|
|
72
|
+
def initialize(self, allow_untested_models: bool = False) -> None:
|
|
73
|
+
"""fetch strings, maxes and DH params (arm_get_constants)."""
|
|
74
|
+
if self.hci.product_id and "MSCR" not in self.hci.product_id:
|
|
75
|
+
# the sdk manual says to check this: other immersion devices
|
|
76
|
+
# share the same handshake
|
|
77
|
+
raise UnsupportedDeviceError(
|
|
78
|
+
f"device identifies as {self.hci.product_id!r}, not a "
|
|
79
|
+
"microscribe ('MSCR')"
|
|
80
|
+
)
|
|
81
|
+
self.info = self.hci.get_info()
|
|
82
|
+
|
|
83
|
+
key = (self.info.product_name, self.info.model_name)
|
|
84
|
+
if key not in TESTED_PRODUCTS and not allow_untested_models:
|
|
85
|
+
raise UnsupportedDeviceError(
|
|
86
|
+
f"device reports product {self.info.product_name!r} model "
|
|
87
|
+
f"{self.info.model_name!r}; this library has only been tested "
|
|
88
|
+
f"against {sorted(TESTED_PRODUCTS)}. pass "
|
|
89
|
+
"allow_untested_models=True to try anyway, and please report "
|
|
90
|
+
"the result."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
self.maxes = self.hci.get_maxes()
|
|
94
|
+
self.radians_factor = [2.0 * math.pi / (m + 1) for m in self.maxes.max_encoder]
|
|
95
|
+
|
|
96
|
+
if self.info.param_format != "Format DH0.5":
|
|
97
|
+
raise UnsupportedDeviceError(
|
|
98
|
+
f"unsupported parameter format {self.info.param_format!r} "
|
|
99
|
+
"(only 'Format DH0.5' is implemented)"
|
|
100
|
+
)
|
|
101
|
+
block = self.hci.get_params()
|
|
102
|
+
if len(block) != 36:
|
|
103
|
+
raise HCIError(f"GET_PARAMS returned {len(block)} bytes, expected 36")
|
|
104
|
+
self._convert_params(block)
|
|
105
|
+
|
|
106
|
+
if "Beta" in self.info.comment:
|
|
107
|
+
ext = self.hci.get_ext_params()
|
|
108
|
+
if len(ext) >= 2:
|
|
109
|
+
raw = _int16(ext[0], ext[1])
|
|
110
|
+
self.beta = raw / 32768.0 * math.pi
|
|
111
|
+
|
|
112
|
+
def _convert_params(self, pb: bytes) -> None:
|
|
113
|
+
# arm_params_DH0_5: 6 x alpha, 6 x A, 6 x D as signed int16 BE.
|
|
114
|
+
# alpha in raw/32768*pi radians; A and D in thousandths of an inch.
|
|
115
|
+
for i in range(6):
|
|
116
|
+
self.alpha[i] = _int16(pb[2 * i], pb[2 * i + 1]) / 32768.0 * math.pi
|
|
117
|
+
for i in range(6):
|
|
118
|
+
j = 12 + 2 * i
|
|
119
|
+
self.a[i] = _int16(pb[j], pb[j + 1]) / 1000.0 * MM_PER_INCH
|
|
120
|
+
for i in range(6):
|
|
121
|
+
j = 24 + 2 * i
|
|
122
|
+
self.d[i] = _int16(pb[j], pb[j + 1]) / 1000.0 * MM_PER_INCH
|
|
123
|
+
|
|
124
|
+
# ----- homing -----
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def homed(self) -> bool:
|
|
128
|
+
return self._homed
|
|
129
|
+
|
|
130
|
+
def home(self) -> None:
|
|
131
|
+
"""re-zero the arm. the arm MUST physically be in its home pose:
|
|
132
|
+
stylus seated in the holder and vertical, counterweight pressed up
|
|
133
|
+
against the bottom of the stylus holder."""
|
|
134
|
+
self.hci.go_home_pos()
|
|
135
|
+
self._homed = True
|
|
136
|
+
|
|
137
|
+
def assume_homed(self) -> None:
|
|
138
|
+
"""declare that the arm is already homed (it was powered on while in
|
|
139
|
+
its home pose, or home() was run earlier this power cycle)."""
|
|
140
|
+
self._homed = True
|
|
141
|
+
|
|
142
|
+
def _require_homed(self) -> None:
|
|
143
|
+
if not self._homed:
|
|
144
|
+
raise NotHomedError(
|
|
145
|
+
"arm not homed: positions would be garbage. put the arm in "
|
|
146
|
+
"its home pose and call home(), or call assume_homed() if it "
|
|
147
|
+
"was powered on in the home pose."
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# ----- sampling -----
|
|
151
|
+
|
|
152
|
+
def joints_rad(self, packet: Packet) -> list[float]:
|
|
153
|
+
self._require_homed()
|
|
154
|
+
assert self.maxes is not None
|
|
155
|
+
out = []
|
|
156
|
+
for i in range(min(NUM_DOF, len(packet.encoders))):
|
|
157
|
+
# counts keep increasing past one revolution ("winding number");
|
|
158
|
+
# the mask assumes max_encoder+1 is a power of two, true for all
|
|
159
|
+
# values seen on real hardware (16384/8192/4096 cpr)
|
|
160
|
+
count = packet.encoders[i] & self.maxes.max_encoder[i]
|
|
161
|
+
out.append(self.radians_factor[i] * count)
|
|
162
|
+
while len(out) < NUM_DOF:
|
|
163
|
+
out.append(0.0)
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
def pose(self, packet: Packet) -> Pose:
|
|
167
|
+
joints = self.joints_rad(packet)
|
|
168
|
+
t = self._calc_t(joints)
|
|
169
|
+
# XYZ_FIXED / ZYX_EULER stylus direction (arm_calc_stylus_dir)
|
|
170
|
+
roll = math.atan2(t[2][1], t[2][2])
|
|
171
|
+
pitch = math.atan2(-t[2][0], math.hypot(t[0][0], t[1][0]))
|
|
172
|
+
yaw = math.atan2(t[1][0], t[0][0])
|
|
173
|
+
return Pose(
|
|
174
|
+
x=t[0][3],
|
|
175
|
+
y=t[1][3],
|
|
176
|
+
z=t[2][3],
|
|
177
|
+
roll=roll,
|
|
178
|
+
pitch=pitch,
|
|
179
|
+
yaw=yaw,
|
|
180
|
+
joints_rad=joints,
|
|
181
|
+
buttons=packet.buttons,
|
|
182
|
+
encoders=packet.encoders,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def sample_pose(self, timer: bool = False) -> Pose:
|
|
186
|
+
self._require_homed()
|
|
187
|
+
return self.pose(self.hci.sample(encoders=6, timer=timer))
|
|
188
|
+
|
|
189
|
+
# ----- kinematics (arm_calc_M / arm_calc_T) -----
|
|
190
|
+
|
|
191
|
+
def _calc_m(self, i: int, theta: float) -> list[list[float]]:
|
|
192
|
+
c, s = math.cos(theta), math.sin(theta)
|
|
193
|
+
ca, sa = math.cos(self.alpha[i]), math.sin(self.alpha[i])
|
|
194
|
+
a, d = self.a[i], self.d[i]
|
|
195
|
+
if i != 2:
|
|
196
|
+
return [
|
|
197
|
+
[c, -s, 0.0, a],
|
|
198
|
+
[s * ca, c * ca, -sa, -sa * d],
|
|
199
|
+
[s * sa, c * sa, ca, ca * d],
|
|
200
|
+
[0.0, 0.0, 0.0, 1.0],
|
|
201
|
+
]
|
|
202
|
+
cb, sb = math.cos(self.beta), math.sin(self.beta)
|
|
203
|
+
return [
|
|
204
|
+
[c * cb, -s * cb, sb, sb * d + a],
|
|
205
|
+
[s * ca + sa * sb * c, c * ca - sa * sb * s, -sa * cb, -sa * cb * d],
|
|
206
|
+
[s * sa - ca * sb * c, c * sa + s * sb * ca, ca * cb, cb * ca * d],
|
|
207
|
+
[0.0, 0.0, 0.0, 1.0],
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
def _calc_t(self, joints: list[float]) -> list[list[float]]:
|
|
211
|
+
t = self._calc_m(0, joints[0])
|
|
212
|
+
for i in range(1, NUM_DOF):
|
|
213
|
+
t = _mul_4x4(t, self._calc_m(i, joints[i]))
|
|
214
|
+
return t
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _int16(hi: int, lo: int) -> int:
|
|
218
|
+
v = (hi << 8) | lo
|
|
219
|
+
return v - 0x10000 if v >= 0x8000 else v
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _mul_4x4(m1: list[list[float]], m2: list[list[float]]) -> list[list[float]]:
|
|
223
|
+
out = [[0.0] * 4 for _ in range(4)]
|
|
224
|
+
for r in range(3):
|
|
225
|
+
for c in range(4):
|
|
226
|
+
out[r][c] = m1[r][0] * m2[0][c] + m1[r][1] * m2[1][c] + m1[r][2] * m2[2][c]
|
|
227
|
+
out[r][3] += m1[r][3]
|
|
228
|
+
out[3][3] = 1.0
|
|
229
|
+
return out
|
microcmm/cli.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""cli for the microscribe digitizer arm. all lengths in mm."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import contextlib
|
|
7
|
+
import csv
|
|
8
|
+
import glob
|
|
9
|
+
import math
|
|
10
|
+
import select
|
|
11
|
+
import sys
|
|
12
|
+
import termios
|
|
13
|
+
import time
|
|
14
|
+
import tty
|
|
15
|
+
from collections.abc import Iterator
|
|
16
|
+
from typing import Any, Protocol
|
|
17
|
+
|
|
18
|
+
from .arm import Pose
|
|
19
|
+
from .device import MicroScribe
|
|
20
|
+
from .hci import HCIError
|
|
21
|
+
|
|
22
|
+
HOME_POSE_HELP = (
|
|
23
|
+
"home pose: stylus seated in its holder and vertical, counterweight "
|
|
24
|
+
"pressed up against the bottom of the stylus holder (user guide fig 9)"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _RowWriter(Protocol):
|
|
29
|
+
def writerow(self, row: list[Any]) -> Any: ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def find_port(explicit: str | None) -> str:
|
|
33
|
+
if explicit:
|
|
34
|
+
return explicit
|
|
35
|
+
candidates = sorted(glob.glob("/dev/cu.usbserial*"))
|
|
36
|
+
if not candidates:
|
|
37
|
+
sys.exit("no /dev/cu.usbserial* port found; pass --port")
|
|
38
|
+
if len(candidates) > 1:
|
|
39
|
+
sys.exit(f"multiple serial ports found, pass --port: {candidates}")
|
|
40
|
+
return candidates[0]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def open_device(args: argparse.Namespace) -> MicroScribe:
|
|
44
|
+
port = find_port(args.port)
|
|
45
|
+
ms = MicroScribe(port, baud=args.baud, debug=args.debug, timeout=args.timeout)
|
|
46
|
+
ms.connect()
|
|
47
|
+
return ms
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _assume_homed(ms: MicroScribe) -> None:
|
|
51
|
+
print(
|
|
52
|
+
"note: assuming the arm is homed (powered on in its home pose, or "
|
|
53
|
+
"`microcmm home` run this power cycle). if positions look wrong, "
|
|
54
|
+
"re-home."
|
|
55
|
+
)
|
|
56
|
+
ms.assume_homed()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def fmt_pose(pose: Pose, joints: bool = False) -> str:
|
|
60
|
+
s = f"{pose.x:9.3f} {pose.y:9.3f} {pose.z:9.3f}"
|
|
61
|
+
s += (
|
|
62
|
+
f" rpy {math.degrees(pose.roll):7.2f} "
|
|
63
|
+
f"{math.degrees(pose.pitch):7.2f} {math.degrees(pose.yaw):7.2f}"
|
|
64
|
+
)
|
|
65
|
+
if joints:
|
|
66
|
+
s += " j " + " ".join(f"{math.degrees(j):7.2f}" for j in pose.joints_rad)
|
|
67
|
+
return s
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def cmd_ports(_args: argparse.Namespace) -> None:
|
|
71
|
+
for p in sorted(glob.glob("/dev/cu.*")):
|
|
72
|
+
print(p)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def cmd_info(args: argparse.Namespace) -> None:
|
|
76
|
+
ms = open_device(args)
|
|
77
|
+
try:
|
|
78
|
+
for name, val in ms.info.fields.items():
|
|
79
|
+
print(f"{name:14s} {val}")
|
|
80
|
+
print(f"{'max encoder':14s} {ms.maxes.max_encoder}")
|
|
81
|
+
arm = ms.arm
|
|
82
|
+
print(
|
|
83
|
+
f"{'alpha (deg)':14s} "
|
|
84
|
+
+ " ".join(f"{math.degrees(v):8.3f}" for v in arm.alpha)
|
|
85
|
+
)
|
|
86
|
+
print(f"{'a (mm)':14s} " + " ".join(f"{v:8.3f}" for v in arm.a))
|
|
87
|
+
print(f"{'d (mm)':14s} " + " ".join(f"{v:8.3f}" for v in arm.d))
|
|
88
|
+
print(f"{'beta (deg)':14s} {math.degrees(arm.beta):.4f}")
|
|
89
|
+
finally:
|
|
90
|
+
ms.disconnect()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def cmd_home(args: argparse.Namespace) -> None:
|
|
94
|
+
print(f"{HOME_POSE_HELP}.")
|
|
95
|
+
print("hold it there, then press enter (ctrl-c to abort)")
|
|
96
|
+
input()
|
|
97
|
+
ms = open_device(args)
|
|
98
|
+
try:
|
|
99
|
+
ms.home()
|
|
100
|
+
print("home position set (until the arm is next power cycled)")
|
|
101
|
+
finally:
|
|
102
|
+
ms.disconnect()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def cmd_point(args: argparse.Namespace) -> None:
|
|
106
|
+
ms = open_device(args)
|
|
107
|
+
try:
|
|
108
|
+
_assume_homed(ms)
|
|
109
|
+
pose = ms.pose()
|
|
110
|
+
if args.raw:
|
|
111
|
+
print(f"buttons {pose.buttons:#04x} encoders {pose.encoders}")
|
|
112
|
+
print(fmt_pose(pose, joints=args.joints))
|
|
113
|
+
finally:
|
|
114
|
+
ms.disconnect()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def cmd_stream(args: argparse.Namespace) -> None:
|
|
118
|
+
ms = open_device(args)
|
|
119
|
+
period = 1.0 / args.hz
|
|
120
|
+
try:
|
|
121
|
+
_assume_homed(ms)
|
|
122
|
+
while True:
|
|
123
|
+
t0 = time.monotonic()
|
|
124
|
+
print(fmt_pose(ms.pose(), joints=args.joints), flush=True)
|
|
125
|
+
dt = period - (time.monotonic() - t0)
|
|
126
|
+
if dt > 0:
|
|
127
|
+
time.sleep(dt)
|
|
128
|
+
except KeyboardInterrupt:
|
|
129
|
+
print()
|
|
130
|
+
finally:
|
|
131
|
+
ms.disconnect()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _capture_events(hz: float, ms: MicroScribe) -> Iterator[Pose]:
|
|
135
|
+
"""poll the arm, yielding a pose each time the user presses space or
|
|
136
|
+
enter. 'q' or ctrl-c ends the stream."""
|
|
137
|
+
print("space or enter: capture point. q: quit.")
|
|
138
|
+
stdin_fd = None
|
|
139
|
+
old_attrs = None
|
|
140
|
+
if sys.stdin.isatty():
|
|
141
|
+
stdin_fd = sys.stdin.fileno()
|
|
142
|
+
old_attrs = termios.tcgetattr(stdin_fd)
|
|
143
|
+
tty.setcbreak(stdin_fd)
|
|
144
|
+
try:
|
|
145
|
+
while True:
|
|
146
|
+
pose = ms.pose()
|
|
147
|
+
keys = ""
|
|
148
|
+
if stdin_fd is not None:
|
|
149
|
+
while select.select([sys.stdin], [], [], 0)[0]:
|
|
150
|
+
keys += sys.stdin.read(1)
|
|
151
|
+
for _ in range(sum(1 for k in keys if k in (" ", "\n", "\r"))):
|
|
152
|
+
yield pose
|
|
153
|
+
if "q" in keys:
|
|
154
|
+
return
|
|
155
|
+
time.sleep(1.0 / hz)
|
|
156
|
+
except KeyboardInterrupt:
|
|
157
|
+
print()
|
|
158
|
+
finally:
|
|
159
|
+
if stdin_fd is not None and old_attrs is not None:
|
|
160
|
+
termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_attrs)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def cmd_capture(args: argparse.Namespace) -> None:
|
|
164
|
+
ms = open_device(args)
|
|
165
|
+
_assume_homed(ms)
|
|
166
|
+
with contextlib.ExitStack() as stack:
|
|
167
|
+
writer: _RowWriter | None = None
|
|
168
|
+
if args.output:
|
|
169
|
+
outfile = stack.enter_context(open(args.output, "w", newline=""))
|
|
170
|
+
writer = csv.writer(outfile)
|
|
171
|
+
writer.writerow(
|
|
172
|
+
["n", "x", "y", "z", "roll_deg", "pitch_deg", "yaw_deg"]
|
|
173
|
+
+ [f"j{i}_deg" for i in range(6)]
|
|
174
|
+
+ [f"enc{i}" for i in range(6)]
|
|
175
|
+
)
|
|
176
|
+
n = 0
|
|
177
|
+
try:
|
|
178
|
+
for pose in _capture_events(args.hz, ms):
|
|
179
|
+
n += 1
|
|
180
|
+
print(f"{n:4d} {fmt_pose(pose, joints=args.joints)}")
|
|
181
|
+
if writer:
|
|
182
|
+
writer.writerow(
|
|
183
|
+
[
|
|
184
|
+
n,
|
|
185
|
+
f"{pose.x:.4f}",
|
|
186
|
+
f"{pose.y:.4f}",
|
|
187
|
+
f"{pose.z:.4f}",
|
|
188
|
+
f"{math.degrees(pose.roll):.3f}",
|
|
189
|
+
f"{math.degrees(pose.pitch):.3f}",
|
|
190
|
+
f"{math.degrees(pose.yaw):.3f}",
|
|
191
|
+
]
|
|
192
|
+
+ [f"{math.degrees(j):.4f}" for j in pose.joints_rad]
|
|
193
|
+
+ [str(e) for e in pose.encoders]
|
|
194
|
+
)
|
|
195
|
+
finally:
|
|
196
|
+
ms.disconnect()
|
|
197
|
+
if writer:
|
|
198
|
+
print(f"{n} points written to {args.output}")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def cmd_dist(args: argparse.Namespace) -> None:
|
|
202
|
+
ms = open_device(args)
|
|
203
|
+
_assume_homed(ms)
|
|
204
|
+
print(
|
|
205
|
+
"measure point pairs: capture A, then B -> distance."
|
|
206
|
+
" repeat pairs (same divots, different arm poses) for stats."
|
|
207
|
+
)
|
|
208
|
+
pending: Pose | None = None
|
|
209
|
+
dists: list[float] = []
|
|
210
|
+
try:
|
|
211
|
+
for pose in _capture_events(args.hz, ms):
|
|
212
|
+
if pending is None:
|
|
213
|
+
pending = pose
|
|
214
|
+
print(f" A {pose.x:9.3f} {pose.y:9.3f} {pose.z:9.3f}")
|
|
215
|
+
else:
|
|
216
|
+
dx = pose.x - pending.x
|
|
217
|
+
dy = pose.y - pending.y
|
|
218
|
+
dz = pose.z - pending.z
|
|
219
|
+
d = math.sqrt(dx * dx + dy * dy + dz * dz)
|
|
220
|
+
dists.append(d)
|
|
221
|
+
print(f" B {pose.x:9.3f} {pose.y:9.3f} {pose.z:9.3f}")
|
|
222
|
+
print(f" -> {d:9.3f} mm (dx {dx:8.3f} dy {dy:8.3f} dz {dz:8.3f})")
|
|
223
|
+
pending = None
|
|
224
|
+
finally:
|
|
225
|
+
ms.disconnect()
|
|
226
|
+
if len(dists) > 1:
|
|
227
|
+
mean = sum(dists) / len(dists)
|
|
228
|
+
spread = max(dists) - min(dists)
|
|
229
|
+
rms = math.sqrt(sum((d - mean) ** 2 for d in dists) / len(dists))
|
|
230
|
+
print(
|
|
231
|
+
f"{len(dists)} pairs: mean {mean:.3f} mm, "
|
|
232
|
+
f"spread {spread:.3f}, rms dev {rms:.3f}"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def main() -> None:
|
|
237
|
+
ap = argparse.ArgumentParser(
|
|
238
|
+
prog="microcmm",
|
|
239
|
+
description="read positions from a microscribe-3d digitizer arm (mm)",
|
|
240
|
+
)
|
|
241
|
+
ap.add_argument(
|
|
242
|
+
"--port", help="serial device (default: the single /dev/cu.usbserial*)"
|
|
243
|
+
)
|
|
244
|
+
ap.add_argument("--baud", type=int, default=115200)
|
|
245
|
+
ap.add_argument(
|
|
246
|
+
"--timeout", type=float, default=5.0, help="handshake timeout seconds"
|
|
247
|
+
)
|
|
248
|
+
ap.add_argument("--debug", action="store_true", help="hex-dump serial i/o")
|
|
249
|
+
sub = ap.add_subparsers(dest="command", required=True)
|
|
250
|
+
|
|
251
|
+
sub.add_parser("ports", help="list serial ports")
|
|
252
|
+
sub.add_parser("info", help="identify the arm and dump its calibration")
|
|
253
|
+
sub.add_parser("home", help=f"set home position ({HOME_POSE_HELP})")
|
|
254
|
+
|
|
255
|
+
p = sub.add_parser("point", help="read one stylus position")
|
|
256
|
+
p.add_argument("--joints", action="store_true", help="also print joint angles")
|
|
257
|
+
p.add_argument("--raw", action="store_true", help="also print raw encoders")
|
|
258
|
+
|
|
259
|
+
p = sub.add_parser("stream", help="continuously print stylus position")
|
|
260
|
+
p.add_argument("--hz", type=float, default=20.0)
|
|
261
|
+
p.add_argument("--joints", action="store_true")
|
|
262
|
+
|
|
263
|
+
p = sub.add_parser("capture", help="capture points on keypress")
|
|
264
|
+
p.add_argument("-o", "--output", help="write points to a csv file")
|
|
265
|
+
p.add_argument("--hz", type=float, default=60.0, help="poll rate")
|
|
266
|
+
p.add_argument("--joints", action="store_true")
|
|
267
|
+
|
|
268
|
+
p = sub.add_parser("dist", help="measure distance between captured point pairs")
|
|
269
|
+
p.add_argument("--hz", type=float, default=60.0, help="poll rate")
|
|
270
|
+
|
|
271
|
+
args = ap.parse_args()
|
|
272
|
+
handlers = {
|
|
273
|
+
"ports": cmd_ports,
|
|
274
|
+
"info": cmd_info,
|
|
275
|
+
"home": cmd_home,
|
|
276
|
+
"point": cmd_point,
|
|
277
|
+
"stream": cmd_stream,
|
|
278
|
+
"capture": cmd_capture,
|
|
279
|
+
"dist": cmd_dist,
|
|
280
|
+
}
|
|
281
|
+
try:
|
|
282
|
+
handlers[args.command](args)
|
|
283
|
+
except HCIError as e:
|
|
284
|
+
sys.exit(f"error: {e}")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
if __name__ == "__main__":
|
|
288
|
+
main()
|
microcmm/device.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""high-level entry point.
|
|
2
|
+
|
|
3
|
+
from microcmm import MicroScribe
|
|
4
|
+
|
|
5
|
+
with MicroScribe("/dev/cu.usbserial-XXXX") as ms:
|
|
6
|
+
# you should be homing right now: arm in its home pose (stylus seated
|
|
7
|
+
# in the holder and vertical, counterweight against the stylus holder)
|
|
8
|
+
ms.home()
|
|
9
|
+
# (advanced: ms.assume_homed() instead, if the arm is already homed
|
|
10
|
+
# this power cycle and you know it)
|
|
11
|
+
print(ms.info.serial_number)
|
|
12
|
+
p = ms.pose()
|
|
13
|
+
print(p.x, p.y, p.z) # mm
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from types import TracebackType
|
|
19
|
+
from typing import Self
|
|
20
|
+
|
|
21
|
+
import serial
|
|
22
|
+
|
|
23
|
+
from .arm import Arm, Pose
|
|
24
|
+
from .hci import HCI, DeviceInfo, Maxes
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MicroScribe:
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
port: str | serial.SerialBase,
|
|
31
|
+
baud: int = 115200,
|
|
32
|
+
debug: bool = False,
|
|
33
|
+
allow_untested_models: bool = False,
|
|
34
|
+
timeout: float = 5.0,
|
|
35
|
+
):
|
|
36
|
+
self._hci = HCI(port, baud=baud, debug=debug)
|
|
37
|
+
self._arm = Arm(self._hci)
|
|
38
|
+
self._allow_untested = allow_untested_models
|
|
39
|
+
self._timeout = timeout
|
|
40
|
+
|
|
41
|
+
def connect(self) -> DeviceInfo:
|
|
42
|
+
self._hci.connect(timeout=self._timeout)
|
|
43
|
+
self._arm.initialize(allow_untested_models=self._allow_untested)
|
|
44
|
+
assert self._arm.info is not None
|
|
45
|
+
return self._arm.info
|
|
46
|
+
|
|
47
|
+
def disconnect(self) -> None:
|
|
48
|
+
self._hci.disconnect()
|
|
49
|
+
|
|
50
|
+
def __enter__(self) -> Self:
|
|
51
|
+
self.connect()
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
def __exit__(
|
|
55
|
+
self,
|
|
56
|
+
exc_type: type[BaseException] | None,
|
|
57
|
+
exc: BaseException | None,
|
|
58
|
+
tb: TracebackType | None,
|
|
59
|
+
) -> None:
|
|
60
|
+
self.disconnect()
|
|
61
|
+
|
|
62
|
+
# ----- homing -----
|
|
63
|
+
|
|
64
|
+
def home(self) -> None:
|
|
65
|
+
"""re-zero. the arm must physically be in its home pose (stylus
|
|
66
|
+
seated and vertical, counterweight against the stylus holder)."""
|
|
67
|
+
self._arm.home()
|
|
68
|
+
|
|
69
|
+
def assume_homed(self) -> None:
|
|
70
|
+
self._arm.assume_homed()
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def homed(self) -> bool:
|
|
74
|
+
return self._arm.homed
|
|
75
|
+
|
|
76
|
+
# ----- data -----
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def info(self) -> DeviceInfo:
|
|
80
|
+
assert self._arm.info is not None, "not connected"
|
|
81
|
+
return self._arm.info
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def maxes(self) -> Maxes:
|
|
85
|
+
assert self._arm.maxes is not None, "not connected"
|
|
86
|
+
return self._arm.maxes
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def arm(self) -> Arm:
|
|
90
|
+
"""the underlying kinematics layer (dh parameters etc.)."""
|
|
91
|
+
return self._arm
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def hci(self) -> HCI:
|
|
95
|
+
"""the underlying protocol layer."""
|
|
96
|
+
return self._hci
|
|
97
|
+
|
|
98
|
+
def pose(self) -> Pose:
|
|
99
|
+
"""one stylus pose: x/y/z in mm, roll/pitch/yaw in radians."""
|
|
100
|
+
return self._arm.sample_pose()
|
microcmm/hci.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""immersion hci serial protocol for microscribe digitizer arms.
|
|
2
|
+
|
|
3
|
+
transcribed from the original immersion sdk (reference/original-sdk/HCI.C,
|
|
4
|
+
HCI.H, SDK1-2a, 1996). the arm autobauds: host repeats "IMMC" until the
|
|
5
|
+
device echoes it back, host flushes, sends "BEGIN", device replies with a
|
|
6
|
+
null-terminated product id string. after that it's a binary request/response
|
|
7
|
+
protocol: host sends one command byte, device echoes it with bit7 set,
|
|
8
|
+
followed by a fixed-size payload.
|
|
9
|
+
|
|
10
|
+
standard (non-config) packets carry 7-bit payload bytes; only the echoed
|
|
11
|
+
command byte has bit7 set, which is the resync mechanism. config command
|
|
12
|
+
payloads (0xC0+) are plain 8-bit.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
|
|
20
|
+
import serial
|
|
21
|
+
|
|
22
|
+
SIGNON = b"IMMC"
|
|
23
|
+
BEGIN = b"BEGIN"
|
|
24
|
+
|
|
25
|
+
# config commands (HCI.H)
|
|
26
|
+
GET_PARAMS = 0xC0
|
|
27
|
+
GET_HOME_REF = 0xC1
|
|
28
|
+
HOME_POS = 0xC2
|
|
29
|
+
SET_HOME = 0xC3
|
|
30
|
+
SET_BAUD = 0xC4
|
|
31
|
+
END_SESSION = 0xC5
|
|
32
|
+
GET_MAXES = 0xC6
|
|
33
|
+
SET_PARAMS = 0xC7
|
|
34
|
+
GET_PROD_NAME = 0xC8
|
|
35
|
+
GET_PROD_ID = 0xC9
|
|
36
|
+
GET_MODEL_NAME = 0xCA
|
|
37
|
+
GET_SERNUM = 0xCB
|
|
38
|
+
GET_COMMENT = 0xCC
|
|
39
|
+
GET_PRM_FORMAT = 0xCD
|
|
40
|
+
GET_VERSION = 0xCE
|
|
41
|
+
REPORT_MOTION = 0xCF
|
|
42
|
+
SET_HOME_REF = 0xD0
|
|
43
|
+
RESTORE_FACTORY = 0xD1
|
|
44
|
+
INSERT_MARKER = 0xD2
|
|
45
|
+
GET_EXT_PARAMS = 0xD3
|
|
46
|
+
|
|
47
|
+
# standard command byte bits (HCI.H)
|
|
48
|
+
TIMER_BIT = 0x20
|
|
49
|
+
# bits1-0 select encoder count: 01 = 5 encoders, 10 = 7, 11 = 6
|
|
50
|
+
_ENCODER_BITS = {0: 0x00, 5: 0x01, 6: 0x03, 7: 0x02}
|
|
51
|
+
|
|
52
|
+
SIGNON_PAUSE = 0.015 # SIGNON_PAUSE in HCI.H
|
|
53
|
+
END_PAUSE = 0.015
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class HCIError(Exception):
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class HCITimeout(HCIError):
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class Packet:
|
|
66
|
+
# buttons is the raw pedal/button state byte from the arm. per the sdk,
|
|
67
|
+
# bit0 = single/right pedal, bit1 = left pedal. left as raw data here
|
|
68
|
+
# because we have no pedal hardware to test the semantics against.
|
|
69
|
+
buttons: int
|
|
70
|
+
encoders: list[int]
|
|
71
|
+
timer: int | None = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class Maxes:
|
|
76
|
+
buttons_supported: int
|
|
77
|
+
max_timer: int
|
|
78
|
+
max_analog: list[int]
|
|
79
|
+
max_encoder: list[int] # 6 values; pulses/rev - 1
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class DeviceInfo:
|
|
84
|
+
product_name: str = ""
|
|
85
|
+
product_id: str = ""
|
|
86
|
+
model_name: str = ""
|
|
87
|
+
serial_number: str = ""
|
|
88
|
+
comment: str = ""
|
|
89
|
+
param_format: str = ""
|
|
90
|
+
version: str = ""
|
|
91
|
+
fields: dict[str, str] = field(default_factory=dict)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class HCI:
|
|
95
|
+
"""low-level protocol session.
|
|
96
|
+
|
|
97
|
+
port is a serial device path (e.g. /dev/cu.usbserial-XXXX). an already-
|
|
98
|
+
opened pyserial-compatible object may be passed instead, mainly for tests.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
port: str | serial.SerialBase,
|
|
104
|
+
baud: int = 115200,
|
|
105
|
+
debug: bool = False,
|
|
106
|
+
):
|
|
107
|
+
self.port_name = port if isinstance(port, str) else ""
|
|
108
|
+
self._injected = None if isinstance(port, str) else port
|
|
109
|
+
self.baud = baud
|
|
110
|
+
self.debug = debug
|
|
111
|
+
self.ser: serial.SerialBase | None = None
|
|
112
|
+
self.product_id = ""
|
|
113
|
+
|
|
114
|
+
# ----- transport -----
|
|
115
|
+
|
|
116
|
+
def _log(self, msg: str) -> None:
|
|
117
|
+
if self.debug:
|
|
118
|
+
print(f"[hci] {msg}")
|
|
119
|
+
|
|
120
|
+
def _write(self, data: bytes) -> None:
|
|
121
|
+
assert self.ser is not None
|
|
122
|
+
self._log(f"tx {data.hex(' ')}")
|
|
123
|
+
self.ser.write(data)
|
|
124
|
+
|
|
125
|
+
def _read_exact(self, n: int, timeout: float) -> bytes:
|
|
126
|
+
assert self.ser is not None
|
|
127
|
+
self.ser.timeout = timeout
|
|
128
|
+
data = self.ser.read(n)
|
|
129
|
+
self._log(f"rx {data.hex(' ')}")
|
|
130
|
+
if len(data) != n:
|
|
131
|
+
raise HCITimeout(f"wanted {n} bytes, got {len(data)}")
|
|
132
|
+
return bytes(data)
|
|
133
|
+
|
|
134
|
+
def _drain(self, quiet: float = 0.05) -> bytes:
|
|
135
|
+
"""read and discard until the line is quiet for `quiet` seconds."""
|
|
136
|
+
assert self.ser is not None
|
|
137
|
+
self.ser.timeout = quiet
|
|
138
|
+
drained = b""
|
|
139
|
+
while True:
|
|
140
|
+
b = self.ser.read(1)
|
|
141
|
+
if not b:
|
|
142
|
+
break
|
|
143
|
+
drained += b
|
|
144
|
+
if drained:
|
|
145
|
+
self._log(f"drained {drained.hex(' ')}")
|
|
146
|
+
return drained
|
|
147
|
+
|
|
148
|
+
# ----- session -----
|
|
149
|
+
|
|
150
|
+
def connect(self, timeout: float = 5.0) -> str:
|
|
151
|
+
"""autobaud handshake (hci_autosynch + hci_begin). returns product id."""
|
|
152
|
+
if self._injected is not None:
|
|
153
|
+
self.ser = self._injected
|
|
154
|
+
else:
|
|
155
|
+
self.ser = serial.Serial(
|
|
156
|
+
self.port_name,
|
|
157
|
+
self.baud,
|
|
158
|
+
bytesize=serial.EIGHTBITS,
|
|
159
|
+
parity=serial.PARITY_NONE,
|
|
160
|
+
stopbits=serial.STOPBITS_ONE,
|
|
161
|
+
timeout=0.05,
|
|
162
|
+
)
|
|
163
|
+
self.ser.reset_input_buffer()
|
|
164
|
+
|
|
165
|
+
deadline = time.monotonic() + timeout
|
|
166
|
+
match = 0
|
|
167
|
+
signed_on = False
|
|
168
|
+
while not signed_on and time.monotonic() < deadline:
|
|
169
|
+
# END_SESSION first so a previously-live session re-enters autobaud
|
|
170
|
+
self._write(bytes([END_SESSION]) + SIGNON)
|
|
171
|
+
time.sleep(SIGNON_PAUSE)
|
|
172
|
+
self.ser.timeout = 0
|
|
173
|
+
while True:
|
|
174
|
+
b = self.ser.read(1)
|
|
175
|
+
if not b:
|
|
176
|
+
break
|
|
177
|
+
if b[0] == SIGNON[match]:
|
|
178
|
+
match += 1
|
|
179
|
+
if match == len(SIGNON):
|
|
180
|
+
signed_on = True
|
|
181
|
+
break
|
|
182
|
+
else:
|
|
183
|
+
match = 1 if b[0] == SIGNON[0] else 0
|
|
184
|
+
if not signed_on:
|
|
185
|
+
self.ser.close()
|
|
186
|
+
self.ser = None
|
|
187
|
+
raise HCITimeout(
|
|
188
|
+
f"no IMMC echo from {self.port_name} at {self.baud} baud "
|
|
189
|
+
f"within {timeout:.1f}s (is the arm powered on?)"
|
|
190
|
+
)
|
|
191
|
+
# discard leftover signon echoes still in flight, then begin
|
|
192
|
+
self._drain(quiet=0.1)
|
|
193
|
+
self._write(BEGIN)
|
|
194
|
+
self.product_id = self._read_string(timeout=1.0)
|
|
195
|
+
return self.product_id
|
|
196
|
+
|
|
197
|
+
def end(self) -> None:
|
|
198
|
+
if self.ser is not None:
|
|
199
|
+
self._write(bytes([END_SESSION]))
|
|
200
|
+
time.sleep(END_PAUSE)
|
|
201
|
+
|
|
202
|
+
def disconnect(self) -> None:
|
|
203
|
+
if self.ser is not None:
|
|
204
|
+
try:
|
|
205
|
+
self._drain(quiet=0.02)
|
|
206
|
+
self.end()
|
|
207
|
+
finally:
|
|
208
|
+
self.ser.close()
|
|
209
|
+
self.ser = None
|
|
210
|
+
|
|
211
|
+
# ----- low-level helpers -----
|
|
212
|
+
|
|
213
|
+
def _read_string(self, timeout: float = 1.0) -> str:
|
|
214
|
+
"""read a null-terminated ascii string."""
|
|
215
|
+
assert self.ser is not None
|
|
216
|
+
deadline = time.monotonic() + timeout
|
|
217
|
+
out = bytearray()
|
|
218
|
+
self.ser.timeout = 0.05
|
|
219
|
+
while time.monotonic() < deadline:
|
|
220
|
+
b = self.ser.read(1)
|
|
221
|
+
if not b:
|
|
222
|
+
continue
|
|
223
|
+
if b[0] == 0:
|
|
224
|
+
s = out.decode("ascii", errors="replace")
|
|
225
|
+
self._log(f"rx string {s!r}")
|
|
226
|
+
return s
|
|
227
|
+
out += b
|
|
228
|
+
raise HCITimeout(f"string read timed out (got {bytes(out)!r})")
|
|
229
|
+
|
|
230
|
+
def _wait_echo(self, cmd: int, timeout: float = 1.0) -> None:
|
|
231
|
+
"""discard bytes until the echoed command byte arrives."""
|
|
232
|
+
assert self.ser is not None
|
|
233
|
+
deadline = time.monotonic() + timeout
|
|
234
|
+
self.ser.timeout = 0.05
|
|
235
|
+
while time.monotonic() < deadline:
|
|
236
|
+
b = self.ser.read(1)
|
|
237
|
+
if b:
|
|
238
|
+
if b[0] == cmd:
|
|
239
|
+
return
|
|
240
|
+
self._log(f"discard {b.hex()} (waiting for {cmd:02x})")
|
|
241
|
+
raise HCITimeout(f"no echo for command {cmd:02x}")
|
|
242
|
+
|
|
243
|
+
# ----- config commands -----
|
|
244
|
+
|
|
245
|
+
def get_string(self, cmd: int) -> str:
|
|
246
|
+
self._write(bytes([cmd]))
|
|
247
|
+
self._wait_echo(cmd)
|
|
248
|
+
return self._read_string()
|
|
249
|
+
|
|
250
|
+
def get_info(self) -> DeviceInfo:
|
|
251
|
+
info = DeviceInfo()
|
|
252
|
+
for name, cmd in [
|
|
253
|
+
("product_name", GET_PROD_NAME),
|
|
254
|
+
("product_id", GET_PROD_ID),
|
|
255
|
+
("model_name", GET_MODEL_NAME),
|
|
256
|
+
("serial_number", GET_SERNUM),
|
|
257
|
+
("comment", GET_COMMENT),
|
|
258
|
+
("param_format", GET_PRM_FORMAT),
|
|
259
|
+
("version", GET_VERSION),
|
|
260
|
+
]:
|
|
261
|
+
val = self.get_string(cmd)
|
|
262
|
+
setattr(info, name, val)
|
|
263
|
+
info.fields[name] = val
|
|
264
|
+
return info
|
|
265
|
+
|
|
266
|
+
def get_maxes(self) -> Maxes:
|
|
267
|
+
"""GET_MAXES: 24 payload bytes.
|
|
268
|
+
|
|
269
|
+
note: the sdk's parser reads 7 encoder maxes (26 bytes) from a
|
|
270
|
+
24-byte buffer -- the 7th overruns. on the wire it is 24 bytes,
|
|
271
|
+
i.e. 6 encoder maxes, which is what we parse.
|
|
272
|
+
"""
|
|
273
|
+
self._write(bytes([GET_MAXES]))
|
|
274
|
+
self._wait_echo(GET_MAXES)
|
|
275
|
+
d = self._read_exact(24, timeout=1.0)
|
|
276
|
+
max_analog = [d[3 + i] << 1 for i in range(8)]
|
|
277
|
+
extra = d[11]
|
|
278
|
+
for i in range(7):
|
|
279
|
+
max_analog[i] |= 1 if extra & (0x40 >> i) else 0
|
|
280
|
+
max_encoder = [(d[12 + 2 * i] << 8) | d[13 + 2 * i] for i in range(6)]
|
|
281
|
+
return Maxes(
|
|
282
|
+
buttons_supported=d[0],
|
|
283
|
+
max_timer=(d[1] << 8) | d[2],
|
|
284
|
+
max_analog=max_analog,
|
|
285
|
+
max_encoder=max_encoder,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
def get_params(self) -> bytes:
|
|
289
|
+
"""GET_PARAMS: echo, then a length byte, then that many bytes."""
|
|
290
|
+
self._write(bytes([GET_PARAMS]))
|
|
291
|
+
self._wait_echo(GET_PARAMS)
|
|
292
|
+
n = self._read_exact(1, timeout=2.0)[0]
|
|
293
|
+
return self._read_exact(n, timeout=2.0)
|
|
294
|
+
|
|
295
|
+
def get_ext_params(self) -> bytes:
|
|
296
|
+
"""GET_EXT_PARAMS (only valid if the comment string contains "Beta")."""
|
|
297
|
+
self._write(bytes([GET_EXT_PARAMS]))
|
|
298
|
+
self._wait_echo(GET_EXT_PARAMS)
|
|
299
|
+
n = self._read_exact(1, timeout=2.0)[0]
|
|
300
|
+
return self._read_exact(n, timeout=2.0)
|
|
301
|
+
|
|
302
|
+
def go_home_pos(self) -> None:
|
|
303
|
+
"""HOME_POS: re-zero angle registers. stylus MUST be in its cradle."""
|
|
304
|
+
self._write(bytes([HOME_POS]))
|
|
305
|
+
self._wait_echo(HOME_POS)
|
|
306
|
+
|
|
307
|
+
# ----- standard sampling -----
|
|
308
|
+
|
|
309
|
+
def sample(
|
|
310
|
+
self, encoders: int = 6, timer: bool = False, timeout: float = 1.0
|
|
311
|
+
) -> Packet:
|
|
312
|
+
"""send one standard command and read the response packet."""
|
|
313
|
+
cmd = _ENCODER_BITS[encoders] | (TIMER_BIT if timer else 0)
|
|
314
|
+
self._write(bytes([cmd]))
|
|
315
|
+
return self.read_packet(cmd, timeout=timeout)
|
|
316
|
+
|
|
317
|
+
def read_packet(self, cmd: int, timeout: float = 1.0) -> Packet:
|
|
318
|
+
"""read one standard response packet for command byte `cmd`."""
|
|
319
|
+
assert self.ser is not None
|
|
320
|
+
echo = cmd | 0x80
|
|
321
|
+
deadline = time.monotonic() + timeout
|
|
322
|
+
self.ser.timeout = 0.05
|
|
323
|
+
while time.monotonic() < deadline:
|
|
324
|
+
b = self.ser.read(1)
|
|
325
|
+
if not b:
|
|
326
|
+
continue
|
|
327
|
+
if b[0] == echo:
|
|
328
|
+
break
|
|
329
|
+
self._log(f"discard {b.hex()} (waiting for {echo:02x})")
|
|
330
|
+
else:
|
|
331
|
+
raise HCITimeout(f"no response packet (cmd {cmd:02x})")
|
|
332
|
+
|
|
333
|
+
n_enc = {0x01: 5, 0x03: 6, 0x02: 7}.get(cmd & 0x03, 0)
|
|
334
|
+
size = 1 + (2 if cmd & TIMER_BIT else 0) + 2 * n_enc
|
|
335
|
+
d = self._read_exact(size, timeout=timeout)
|
|
336
|
+
if any(b & 0x80 for b in d):
|
|
337
|
+
# only the header byte of a standard packet may have bit 7 set;
|
|
338
|
+
# a set bit inside the payload means corruption (doc ch. 4)
|
|
339
|
+
self._drain()
|
|
340
|
+
raise HCIError("corrupt packet (bit 7 set in payload byte)")
|
|
341
|
+
pos = 0
|
|
342
|
+
buttons = d[pos]
|
|
343
|
+
pos += 1
|
|
344
|
+
tm = None
|
|
345
|
+
if cmd & TIMER_BIT:
|
|
346
|
+
tm = (d[pos] << 7) | d[pos + 1]
|
|
347
|
+
pos += 2
|
|
348
|
+
enc = []
|
|
349
|
+
for _ in range(n_enc):
|
|
350
|
+
enc.append((d[pos] << 7) | d[pos + 1])
|
|
351
|
+
pos += 2
|
|
352
|
+
return Packet(buttons=buttons, encoders=enc, timer=tm)
|
|
353
|
+
|
|
354
|
+
def start_motion(
|
|
355
|
+
self,
|
|
356
|
+
encoders: int = 6,
|
|
357
|
+
timer: bool = False,
|
|
358
|
+
delay_ms: int = 0,
|
|
359
|
+
active_buttons: int = 0,
|
|
360
|
+
encoder_deltas: list[int] | None = None,
|
|
361
|
+
) -> int:
|
|
362
|
+
"""REPORT_MOTION: arm streams packets on its own. returns the std
|
|
363
|
+
command byte whose echoes to expect. cancel with end_motion()."""
|
|
364
|
+
if encoders > 6:
|
|
365
|
+
raise ValueError("start_motion supports up to 6 encoders")
|
|
366
|
+
cmd = _ENCODER_BITS[encoders] | (TIMER_BIT if timer else 0)
|
|
367
|
+
deltas = encoder_deltas or [0] * 6
|
|
368
|
+
msg = bytearray(
|
|
369
|
+
[
|
|
370
|
+
REPORT_MOTION,
|
|
371
|
+
(delay_ms >> 8) & 0xFF,
|
|
372
|
+
delay_ms & 0xFF,
|
|
373
|
+
cmd,
|
|
374
|
+
active_buttons,
|
|
375
|
+
]
|
|
376
|
+
)
|
|
377
|
+
msg += bytes(8) # analog deltas, unused
|
|
378
|
+
for dlt in deltas[:6]:
|
|
379
|
+
msg += bytes([(dlt >> 8) & 0xFF, dlt & 0xFF])
|
|
380
|
+
self._write(bytes(msg))
|
|
381
|
+
return cmd
|
|
382
|
+
|
|
383
|
+
def end_motion(self) -> None:
|
|
384
|
+
self._write(bytes([0x00]))
|
|
385
|
+
time.sleep(0.05)
|
|
386
|
+
self._drain()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: microcmm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python library + CLI for MicroScribe-3D digitizer arms (RS-232, no Windows DLL)
|
|
5
|
+
Project-URL: Repository, https://github.com/Revise-Robotics/microcmm
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Requires-Dist: pyserial>=3.5
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# microcmm
|
|
13
|
+
|
|
14
|
+
Python library + CLI for reading positions from a MicroScribe-3D digitizer arm
|
|
15
|
+
over its RS-232 serial port. Speaks the Immersion HCI protocol directly (no
|
|
16
|
+
Windows DLL) and does the forward kinematics on the host using the arm's own
|
|
17
|
+
EEPROM calibration. Millimeters everywhere.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
pip install microcmm
|
|
23
|
+
# or
|
|
24
|
+
uv add microcmm
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from microcmm import MicroScribe
|
|
31
|
+
|
|
32
|
+
with MicroScribe("/dev/cu.usbserial-XXXX") as ms:
|
|
33
|
+
# You should be homing right now: arm in its home pose (stylus seated
|
|
34
|
+
# in the holder and vertical, counterweight against the stylus holder).
|
|
35
|
+
ms.home()
|
|
36
|
+
# (Advanced: ms.assume_homed() if the arm is already homed this power cycle.)
|
|
37
|
+
print(ms.info.serial_number)
|
|
38
|
+
p = ms.pose()
|
|
39
|
+
print(p.x, p.y, p.z)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## CLI
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
microcmm <command>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- `info` - identify the arm, dump its calibration constants
|
|
49
|
+
- `point` - read one stylus position
|
|
50
|
+
- `stream` - continuously print positions
|
|
51
|
+
- `capture -o points.csv` - capture points on keypress, write CSV
|
|
52
|
+
- `dist` - measure distances between captured point pairs
|
|
53
|
+
- `home` - set the home position (arm must be in its home pose)
|
|
54
|
+
- `ports` - list serial ports
|
|
55
|
+
|
|
56
|
+
## Notes
|
|
57
|
+
|
|
58
|
+
- **Homing is mandatory.** The arm only reads correctly if it was in its home
|
|
59
|
+
pose (stylus seated in the holder and vertical, counterweight pressed
|
|
60
|
+
against the bottom of the stylus holder) at power-on or when `home` was
|
|
61
|
+
sent. This is lost at every power cycle. The library refuses to return
|
|
62
|
+
positions until you call `home()` or explicitly `assume_homed()` - an
|
|
63
|
+
unhomed arm produces garbage that looks plausible.
|
|
64
|
+
- **Only tested on a MicroScribe-3D model D (5-DOF).** The library refuses
|
|
65
|
+
other models by precaution, not because they can't work - if you have one,
|
|
66
|
+
run with `allow_untested_models=True` and report back, we'll add it.
|
|
67
|
+
- **Pedals are not implemented** because we don't have one. The raw button
|
|
68
|
+
byte is exposed on every packet if you want to try.
|
|
69
|
+
- 5-DOF arms have no roll about the stylus axis; the reported orientation
|
|
70
|
+
(XYZ-fixed roll/pitch/yaw) reflects that.
|
|
71
|
+
- Accuracy validated with a 45-point single-divot pivot test: 0.66 mm RMS.
|
|
72
|
+
Avoid poses with the stylus aligned with the forearm (singularity).
|
|
73
|
+
- Not implemented, on purpose: analog channels, baud switching, EEPROM
|
|
74
|
+
writes (SET_PARAMS / SET_HOME / RESTORE_FACTORY), motion-report streaming,
|
|
75
|
+
custom tip offsets (MSTIP.DAT). Ask if you need one.
|
|
76
|
+
|
|
77
|
+
## Dev
|
|
78
|
+
|
|
79
|
+
Install dependencies with `uv sync`, then:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
uv run pytest
|
|
83
|
+
uv run ruff check src tests
|
|
84
|
+
uv run mypy src tests
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Tests run without hardware: the kinematics is checked against golden
|
|
88
|
+
joint-angles-to-XYZ values captured from a real arm.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
microcmm/__init__.py,sha256=LCxXk_8QCNlg5p00j6dR2aiB58kG75-lin1QZzfUt0M,366
|
|
2
|
+
microcmm/arm.py,sha256=iUwsaMoZLkKpWhXc1cBw55cvjj19Nx_sU1Vh2TVSVsY,8298
|
|
3
|
+
microcmm/cli.py,sha256=fUxSbdZez54yrPiRrVlfUhRrrEGi2U-PSLZ2BGEA61w,9477
|
|
4
|
+
microcmm/device.py,sha256=CR4n52YKNZhxwYGNcs7zsipw3Ve82az9d_6WGNTwS98,2676
|
|
5
|
+
microcmm/hci.py,sha256=NCyJscWRv_c1sYaaYygRiSXFsUu8PuX12c4M4uOMFE0,12460
|
|
6
|
+
microcmm-0.1.0.dist-info/METADATA,sha256=6J89ul8epPAaJw4FfFFc7Lvi-burhn52F2aiY2KrK_E,3025
|
|
7
|
+
microcmm-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
8
|
+
microcmm-0.1.0.dist-info/entry_points.txt,sha256=yOrNisK5Cru16bNk38YmbJQVSXRoA8sSCox7E7O-Bgs,47
|
|
9
|
+
microcmm-0.1.0.dist-info/licenses/LICENSE,sha256=09e-X6HyXyR4pimE6NbZMk5L-fd3PX12bCYFKooCr8U,1070
|
|
10
|
+
microcmm-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Greg Sadetsky
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|