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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ microcmm = microcmm.cli:main
@@ -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.