one-axis-stage 0.0.2__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.
- one_axis_stage/__init__.py +20 -0
- one_axis_stage/api.py +197 -0
- one_axis_stage/axis.py +123 -0
- one_axis_stage/connection.py +165 -0
- one_axis_stage/controller.py +181 -0
- one_axis_stage/interface.py +94 -0
- one_axis_stage/py.typed +0 -0
- one_axis_stage-0.0.2.dist-info/METADATA +140 -0
- one_axis_stage-0.0.2.dist-info/RECORD +11 -0
- one_axis_stage-0.0.2.dist-info/WHEEL +4 -0
- one_axis_stage-0.0.2.dist-info/licenses/LICENSE +29 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
__author__ = "Lars B. Rollik"
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("one-axis-stage")
|
|
7
|
+
except PackageNotFoundError:
|
|
8
|
+
__version__ = "0.0.1"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
OP_MODE_LOOKUP_TO_STR = {
|
|
12
|
+
0: "OP_POSITION",
|
|
13
|
+
1: "OP_EXTENDED_POSITION",
|
|
14
|
+
2: "OP_CURRENT_BASED_POSITION",
|
|
15
|
+
3: "OP_VELOCITY",
|
|
16
|
+
4: "OP_PWM",
|
|
17
|
+
5: "OP_CURRENT",
|
|
18
|
+
}
|
|
19
|
+
OP_MODE_LOOKUP = {v: k for k, v in OP_MODE_LOOKUP_TO_STR.items()}
|
|
20
|
+
BAUDRATE_LOOKUP = {0: 9600, 1: 57600, 2: 115200, 3: 1000000}
|
one_axis_stage/api.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from one_axis_stage import BAUDRATE_LOOKUP, OP_MODE_LOOKUP, OP_MODE_LOOKUP_TO_STR
|
|
7
|
+
from one_axis_stage.connection import StageSerialConnection
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class StageAPI(StageSerialConnection):
|
|
11
|
+
stage_id: int = 0
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self, serial_port: str, baudrate: int = 115200, timeout: float = 1
|
|
15
|
+
) -> None:
|
|
16
|
+
# init serial connection
|
|
17
|
+
super().__init__(serial_port=serial_port, baudrate=baudrate, timeout=timeout)
|
|
18
|
+
|
|
19
|
+
# --- GETTERS ---
|
|
20
|
+
|
|
21
|
+
def get_stage_id(self) -> int:
|
|
22
|
+
self.send(command="e", order="c")
|
|
23
|
+
info_json = self.read_line()
|
|
24
|
+
|
|
25
|
+
# to dict
|
|
26
|
+
stage_id_dict = json.loads(info_json)
|
|
27
|
+
stage_id = stage_id_dict["stage_id"]
|
|
28
|
+
self.stage_id = stage_id
|
|
29
|
+
return stage_id
|
|
30
|
+
|
|
31
|
+
def scan_for_devices(self):
|
|
32
|
+
self.send(command="s", order="c")
|
|
33
|
+
time.sleep(2)
|
|
34
|
+
|
|
35
|
+
# while returning lines, read until no more lines
|
|
36
|
+
scan_result = ""
|
|
37
|
+
while self.connection.in_waiting > 0:
|
|
38
|
+
line = self.read_line()
|
|
39
|
+
scan_result += line + "\n"
|
|
40
|
+
logging.debug(f"Scan line: {line}")
|
|
41
|
+
time.sleep(2)
|
|
42
|
+
|
|
43
|
+
return scan_result
|
|
44
|
+
|
|
45
|
+
def get_info(self, device_id: int) -> str:
|
|
46
|
+
""""""
|
|
47
|
+
self.send(command="i", data=device_id, order="!cH")
|
|
48
|
+
info_json = self.read_line()
|
|
49
|
+
|
|
50
|
+
# to dict
|
|
51
|
+
info_dict = json.loads(info_json)
|
|
52
|
+
|
|
53
|
+
# resolve baud_rate_int -> baud_rate, same for operating mode
|
|
54
|
+
info_dict["baud_rate"] = BAUDRATE_LOOKUP.get(info_dict["baud_rate_int"])
|
|
55
|
+
info_dict["operating_mode"] = self._op_mode_int_to_str(
|
|
56
|
+
info_dict["operating_mode_int"]
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
return info_dict
|
|
60
|
+
|
|
61
|
+
def get_info_all(self, device_ids: list[Any]) -> str:
|
|
62
|
+
""""""
|
|
63
|
+
info_all = []
|
|
64
|
+
for device_id in device_ids:
|
|
65
|
+
info_all.append(self.get_info(device_id))
|
|
66
|
+
|
|
67
|
+
# data_order = "!c" + len(device_ids) * "H"
|
|
68
|
+
# self.send(command="I", data=device_ids, order=data_order)
|
|
69
|
+
# info_json = self.read_line()
|
|
70
|
+
# # to dict
|
|
71
|
+
# info_dict = json.loads(info_json)
|
|
72
|
+
# return info_dict
|
|
73
|
+
return info_all
|
|
74
|
+
|
|
75
|
+
def get_position(self, device_id: int) -> int:
|
|
76
|
+
"""
|
|
77
|
+
Get the position of a device.
|
|
78
|
+
"""
|
|
79
|
+
self.send(command="p", data=device_id, order="!cH")
|
|
80
|
+
# read 2 bytes & combine bytes to int
|
|
81
|
+
position = self.read_bytes(n_bytes=2, unpack_order="!H")
|
|
82
|
+
if isinstance(position, tuple):
|
|
83
|
+
position = position[0]
|
|
84
|
+
|
|
85
|
+
logging.debug(f"Position: {position}")
|
|
86
|
+
return position
|
|
87
|
+
|
|
88
|
+
# --- SETTERS ---
|
|
89
|
+
|
|
90
|
+
def set_position(self, device_id: int, position: int) -> None:
|
|
91
|
+
"""
|
|
92
|
+
Set the position of a device.
|
|
93
|
+
"""
|
|
94
|
+
self.send(
|
|
95
|
+
command="m",
|
|
96
|
+
data=[device_id, position],
|
|
97
|
+
order="!cHH",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def set_position_multiple(self, position_tuples: list[tuple[int, int]]) -> None:
|
|
101
|
+
"""
|
|
102
|
+
Set the position of multiple devices.
|
|
103
|
+
"""
|
|
104
|
+
logging.debug(f"Moving to position: {position_tuples}")
|
|
105
|
+
|
|
106
|
+
data = []
|
|
107
|
+
data_order = "!c"
|
|
108
|
+
for device_id, position in position_tuples:
|
|
109
|
+
data.append(device_id)
|
|
110
|
+
data.append(position)
|
|
111
|
+
data_order += "HH"
|
|
112
|
+
|
|
113
|
+
logging.debug(f"Data order: {data_order}")
|
|
114
|
+
logging.debug(f"Position tuples: {data}")
|
|
115
|
+
|
|
116
|
+
# send
|
|
117
|
+
self.send(command="M", data=data, order=data_order)
|
|
118
|
+
|
|
119
|
+
def set_baudrate(
|
|
120
|
+
self, device_id: int, current_baudrate: int, new_baudrate: int
|
|
121
|
+
) -> None:
|
|
122
|
+
"""
|
|
123
|
+
Set the baudrate of a device.
|
|
124
|
+
"""
|
|
125
|
+
# TODO: assert baudrate in baudrate list
|
|
126
|
+
|
|
127
|
+
self.send(
|
|
128
|
+
command="b",
|
|
129
|
+
data=[device_id, current_baudrate, new_baudrate],
|
|
130
|
+
order="!cHII",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def set_device_id(self, current_device_id: int, new_device_id: int) -> None:
|
|
134
|
+
"""
|
|
135
|
+
Set the device ID of a device.
|
|
136
|
+
"""
|
|
137
|
+
# TODO: assert device id range
|
|
138
|
+
|
|
139
|
+
self.send(
|
|
140
|
+
command="d",
|
|
141
|
+
data=[current_device_id, new_device_id],
|
|
142
|
+
order="!cHH",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def set_velocity(self, device_id: int, velocity: int) -> None:
|
|
146
|
+
"""
|
|
147
|
+
Set the velocity of a device.
|
|
148
|
+
"""
|
|
149
|
+
# TODO: confirm that this is the velocity limit
|
|
150
|
+
assert velocity >= 0 and velocity <= 254, f"Invalid velocity: {velocity}"
|
|
151
|
+
|
|
152
|
+
self.send(
|
|
153
|
+
command="v",
|
|
154
|
+
data=[device_id, velocity],
|
|
155
|
+
order="!cHH",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def _op_mode_str_to_int(self, op_mode: str) -> int:
|
|
159
|
+
"""
|
|
160
|
+
Resolve the operating mode code from the mode string.
|
|
161
|
+
"""
|
|
162
|
+
return OP_MODE_LOOKUP.get(op_mode)
|
|
163
|
+
|
|
164
|
+
def _op_mode_int_to_str(self, op_mode: int) -> str:
|
|
165
|
+
"""
|
|
166
|
+
Resolve the operating mode code from the mode string.
|
|
167
|
+
"""
|
|
168
|
+
return OP_MODE_LOOKUP_TO_STR.get(op_mode)
|
|
169
|
+
|
|
170
|
+
def set_operating_mode(self, device_id: int, op_mode: str | int) -> None:
|
|
171
|
+
"""
|
|
172
|
+
Set the operating mode of a device.
|
|
173
|
+
"""
|
|
174
|
+
# resolve mode code
|
|
175
|
+
assert op_mode is not None, f"Invalid operating mode: {op_mode}"
|
|
176
|
+
|
|
177
|
+
if isinstance(op_mode, str):
|
|
178
|
+
op_mode = self._op_mode_str_to_int(op_mode=op_mode)
|
|
179
|
+
|
|
180
|
+
self.send(
|
|
181
|
+
command="o",
|
|
182
|
+
data=[device_id, op_mode],
|
|
183
|
+
order="!cHH",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def flash(self, device_id: int, duration_ms: int, repeats: int) -> None:
|
|
187
|
+
"""
|
|
188
|
+
Flash the LED of a device.
|
|
189
|
+
"""
|
|
190
|
+
assert isinstance(duration_ms, int)
|
|
191
|
+
assert isinstance(repeats, int)
|
|
192
|
+
|
|
193
|
+
self.send(
|
|
194
|
+
command="f",
|
|
195
|
+
data=[device_id, duration_ms, repeats],
|
|
196
|
+
order="!cHHH",
|
|
197
|
+
)
|
one_axis_stage/axis.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from one_axis_stage.api import StageAPI
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class StageAxis:
|
|
7
|
+
name: str
|
|
8
|
+
id: int
|
|
9
|
+
|
|
10
|
+
position_raw: int = -1
|
|
11
|
+
position_min: int = -1
|
|
12
|
+
position_max: int = -1
|
|
13
|
+
velocity_max: int = 0
|
|
14
|
+
operating_mode: str = "OP_POSITION"
|
|
15
|
+
api: StageAPI
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
name: str,
|
|
20
|
+
id: int,
|
|
21
|
+
# position_raw: int,
|
|
22
|
+
position_min: int,
|
|
23
|
+
position_max: int,
|
|
24
|
+
velocity_max: int,
|
|
25
|
+
operating_mode: str,
|
|
26
|
+
api: StageAPI,
|
|
27
|
+
**kwargs,
|
|
28
|
+
) -> None:
|
|
29
|
+
self.name = name
|
|
30
|
+
self.id = id
|
|
31
|
+
# self.position_raw = position_raw
|
|
32
|
+
self.position_min = position_min
|
|
33
|
+
self.position_max = position_max
|
|
34
|
+
self.velocity_max = velocity_max
|
|
35
|
+
self.operating_mode = operating_mode
|
|
36
|
+
self.api = api
|
|
37
|
+
|
|
38
|
+
# set device mode
|
|
39
|
+
# self.set_operating_mode(device_id=self.id, op_mode=self.operating_mode)
|
|
40
|
+
# self.set_velocity(device_id=self.id, velocity=self.velocity_max)
|
|
41
|
+
|
|
42
|
+
# get attrs
|
|
43
|
+
# self.
|
|
44
|
+
|
|
45
|
+
def __repr__(self) -> str:
|
|
46
|
+
return f"StageAxis({self.name})"
|
|
47
|
+
|
|
48
|
+
def __str__(self) -> str:
|
|
49
|
+
return f"StageAxis: {self.name}"
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> dict:
|
|
52
|
+
return {
|
|
53
|
+
"name": self.name,
|
|
54
|
+
"id": self.id,
|
|
55
|
+
"position_raw": self.position_raw,
|
|
56
|
+
"position_min": self.position_min,
|
|
57
|
+
"position_max": self.position_max,
|
|
58
|
+
"velocity_max": self.velocity_max,
|
|
59
|
+
"operating_mode": self.operating_mode,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
def get_info(self):
|
|
63
|
+
"""
|
|
64
|
+
Get the information of the device.
|
|
65
|
+
|
|
66
|
+
Returns
|
|
67
|
+
-------
|
|
68
|
+
dict
|
|
69
|
+
Information of the device.
|
|
70
|
+
|
|
71
|
+
"""
|
|
72
|
+
info = self.api.get_info(device_id=self.id)
|
|
73
|
+
|
|
74
|
+
# update Axis attributes
|
|
75
|
+
self.position_raw = info["position_raw"]
|
|
76
|
+
# self.position_deg = info["position_deg"]
|
|
77
|
+
self.velocity_max = info["velocity_max"]
|
|
78
|
+
self.operating_mode = info["operating_mode"]
|
|
79
|
+
|
|
80
|
+
return info
|
|
81
|
+
|
|
82
|
+
def get_position(self):
|
|
83
|
+
"""
|
|
84
|
+
Get the position of the device.
|
|
85
|
+
"""
|
|
86
|
+
self.position_raw = self.api.get_position(device_id=self.id)
|
|
87
|
+
logging.debug(f"Get position: {self.position_raw}")
|
|
88
|
+
return self.position_raw
|
|
89
|
+
|
|
90
|
+
def set_position(self, position: int) -> None:
|
|
91
|
+
"""
|
|
92
|
+
Set the position of the device.
|
|
93
|
+
"""
|
|
94
|
+
assert position >= self.position_min and position <= self.position_max, (
|
|
95
|
+
f"Invalid position: {position}"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
self.api.set_position(device_id=self.id, position=position)
|
|
99
|
+
self.position_raw = position
|
|
100
|
+
logging.debug(f"Set position: {position}")
|
|
101
|
+
|
|
102
|
+
def set_velocity(self, velocity: int) -> None:
|
|
103
|
+
"""
|
|
104
|
+
Set the velocity of the device.
|
|
105
|
+
"""
|
|
106
|
+
assert velocity >= 0 and velocity <= self.velocity_max, (
|
|
107
|
+
f"Invalid velocity: {velocity}"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
self.api.set_velocity(device_id=self.id, velocity=velocity)
|
|
111
|
+
logging.debug(f"Set velocity: {velocity}")
|
|
112
|
+
|
|
113
|
+
def set_operating_mode(self, op_mode: str | int) -> None:
|
|
114
|
+
"""
|
|
115
|
+
Set the operating mode of the device.
|
|
116
|
+
"""
|
|
117
|
+
assert op_mode is not None, f"Invalid operating mode: {op_mode}"
|
|
118
|
+
|
|
119
|
+
if isinstance(op_mode, str):
|
|
120
|
+
self._op_mode_str_to_int(op_mode=op_mode) # type: ignore[attr-defined]
|
|
121
|
+
|
|
122
|
+
self.api.set_operating_mode(device_id=self.id, op_mode=op_mode)
|
|
123
|
+
logging.debug(f"Set operating mode: {op_mode}")
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import struct
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from serial import Serial
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class StageSerialConnection:
|
|
9
|
+
serial_port: str | None = None
|
|
10
|
+
baudrate: int | None = None
|
|
11
|
+
timeout: float = 1
|
|
12
|
+
connection: Serial | None = None
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
serial_port: str | None = None,
|
|
17
|
+
baudrate: int | None = None,
|
|
18
|
+
timeout: float | None = None,
|
|
19
|
+
**kwargs: Any,
|
|
20
|
+
) -> None:
|
|
21
|
+
self.serial_port = serial_port
|
|
22
|
+
self.baudrate = baudrate or 115200
|
|
23
|
+
self.timeout = timeout or 0.1
|
|
24
|
+
|
|
25
|
+
def dict(self) -> dict:
|
|
26
|
+
class_data = {
|
|
27
|
+
"serial_port": self.serial_port,
|
|
28
|
+
"baudrate": self.baudrate,
|
|
29
|
+
"timeout": self.timeout,
|
|
30
|
+
}
|
|
31
|
+
return class_data
|
|
32
|
+
|
|
33
|
+
def __repr__(self) -> str:
|
|
34
|
+
return (
|
|
35
|
+
f"SerialConnection(serial_port={self.serial_port}, "
|
|
36
|
+
f"baudrate={self.baudrate}, "
|
|
37
|
+
f"timeout={self.timeout})"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def __str__(self) -> str:
|
|
41
|
+
return (
|
|
42
|
+
f"SerialConnection: {self.serial_port} @ {self.baudrate} baud, "
|
|
43
|
+
f"timeout={self.timeout}"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def connected(self) -> bool:
|
|
48
|
+
if self.connection is not None:
|
|
49
|
+
return self.connection.is_open
|
|
50
|
+
else:
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
def connect(self) -> "StageSerialConnection":
|
|
54
|
+
if not self.connected:
|
|
55
|
+
self.connection = Serial(
|
|
56
|
+
port=self.serial_port,
|
|
57
|
+
baudrate=self.baudrate,
|
|
58
|
+
timeout=self.timeout,
|
|
59
|
+
)
|
|
60
|
+
# is open?
|
|
61
|
+
if self.connection.is_open:
|
|
62
|
+
logging.info(
|
|
63
|
+
f"Connected to {self.serial_port} at {self.baudrate} baud."
|
|
64
|
+
)
|
|
65
|
+
else:
|
|
66
|
+
logging.error(f"Failed to open serial port {self.serial_port}.")
|
|
67
|
+
|
|
68
|
+
# clear buffer
|
|
69
|
+
self.connection.read(self.connection.in_waiting)
|
|
70
|
+
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
def disconnect(self) -> None:
|
|
74
|
+
if self.connection is not None:
|
|
75
|
+
self.connection.close()
|
|
76
|
+
self.connection = None
|
|
77
|
+
logging.info(f"Disconnected from {self.serial_port}.")
|
|
78
|
+
|
|
79
|
+
def _encode(self, data: Any, order: str) -> bytes:
|
|
80
|
+
"""Encode & pack as byte struct & flank by start/stop bytes."""
|
|
81
|
+
# check that data is list
|
|
82
|
+
if not isinstance(data, list):
|
|
83
|
+
data = [data]
|
|
84
|
+
|
|
85
|
+
# encode str to bytes
|
|
86
|
+
data_encoded = [
|
|
87
|
+
item.encode() if isinstance(item, str) else item for item in data
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
# pack the data
|
|
91
|
+
data_packed = struct.pack(order, *data_encoded)
|
|
92
|
+
|
|
93
|
+
# flank the packed data with start/stop bytes </>
|
|
94
|
+
message = b"<" + data_packed + b">"
|
|
95
|
+
|
|
96
|
+
logging.debug(f"Encoded message: '{str(message)}'")
|
|
97
|
+
return message
|
|
98
|
+
|
|
99
|
+
def _clear_buffer(self):
|
|
100
|
+
self.connection.read(self.connection.in_waiting)
|
|
101
|
+
return not self.connection.in_waiting
|
|
102
|
+
|
|
103
|
+
def send(self, command: str, data: Any = None, order: str = None) -> None:
|
|
104
|
+
""""""
|
|
105
|
+
assert isinstance(command, str)
|
|
106
|
+
assert isinstance(data, list | int | str | type(None))
|
|
107
|
+
assert isinstance(order, str)
|
|
108
|
+
|
|
109
|
+
# combine command and data
|
|
110
|
+
if data is not None:
|
|
111
|
+
if not isinstance(data, list):
|
|
112
|
+
data = [data]
|
|
113
|
+
raw_data = [command] + data
|
|
114
|
+
else:
|
|
115
|
+
raw_data = command
|
|
116
|
+
|
|
117
|
+
# encode/pack
|
|
118
|
+
data_to_send = self._encode(raw_data, order=order)
|
|
119
|
+
|
|
120
|
+
# send data
|
|
121
|
+
if self.connected:
|
|
122
|
+
self._clear_buffer()
|
|
123
|
+
self.connection.write(data_to_send)
|
|
124
|
+
self.connection.flush()
|
|
125
|
+
logging.debug(f"Sent data: {data_to_send}")
|
|
126
|
+
|
|
127
|
+
def read_bytes(
|
|
128
|
+
self, n_bytes: int = None, unpack_order: str = None
|
|
129
|
+
) -> tuple[Any, ...]:
|
|
130
|
+
"""
|
|
131
|
+
Read n_bytes from the serial port and unpack them according to the
|
|
132
|
+
specified unpack_order.
|
|
133
|
+
The unpack_order should be a format string compatible with the
|
|
134
|
+
struct module.
|
|
135
|
+
|
|
136
|
+
Parameters
|
|
137
|
+
----------
|
|
138
|
+
n_bytes : int
|
|
139
|
+
unpack_order : str
|
|
140
|
+
|
|
141
|
+
Returns
|
|
142
|
+
-------
|
|
143
|
+
tuple
|
|
144
|
+
Unpacked data as a tuple of values.
|
|
145
|
+
|
|
146
|
+
"""
|
|
147
|
+
raw_data = self.connection.read(n_bytes)
|
|
148
|
+
|
|
149
|
+
# Check if the correct amount of data was read
|
|
150
|
+
if len(raw_data) != n_bytes:
|
|
151
|
+
raise ValueError(f"Did not receive {n_bytes} bytes from serial port")
|
|
152
|
+
|
|
153
|
+
# Unpack the data as separate variables
|
|
154
|
+
unpacked_bytes = struct.unpack(unpack_order, raw_data)
|
|
155
|
+
|
|
156
|
+
logging.debug(f"Unpacked bytes: {unpacked_bytes}")
|
|
157
|
+
return unpacked_bytes
|
|
158
|
+
|
|
159
|
+
def read_line(self) -> str:
|
|
160
|
+
"""
|
|
161
|
+
Read a line from the serial port and decode it to a string.
|
|
162
|
+
"""
|
|
163
|
+
line = self.connection.readline().decode("utf-8").strip()
|
|
164
|
+
logging.debug(f"Received line: {line}")
|
|
165
|
+
return line
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
from one_axis_stage.api import StageAPI
|
|
8
|
+
from one_axis_stage.axis import StageAxis
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class StageController:
|
|
12
|
+
serial_port: str
|
|
13
|
+
baudrate: int
|
|
14
|
+
timeout: float
|
|
15
|
+
|
|
16
|
+
api: StageAPI
|
|
17
|
+
axes: dict = {}
|
|
18
|
+
known_positions: dict = {}
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
serial_port: str | None = None,
|
|
23
|
+
baudrate: int | None = None,
|
|
24
|
+
timeout: float | None = None,
|
|
25
|
+
# **kwargs,
|
|
26
|
+
) -> None:
|
|
27
|
+
if serial_port is not None:
|
|
28
|
+
self.serial_port = str(serial_port)
|
|
29
|
+
if baudrate is not None:
|
|
30
|
+
self.baudrate = int(baudrate)
|
|
31
|
+
if timeout is not None:
|
|
32
|
+
self.timeout = float(timeout)
|
|
33
|
+
|
|
34
|
+
# if serial port is provided, connect to the stage
|
|
35
|
+
if self.serial_port is not None:
|
|
36
|
+
self.connect()
|
|
37
|
+
|
|
38
|
+
# getter/setter for config dict
|
|
39
|
+
@property
|
|
40
|
+
def config(self) -> dict:
|
|
41
|
+
return {
|
|
42
|
+
"stage_id": self.api.stage_id,
|
|
43
|
+
"connection": {
|
|
44
|
+
"serial_port": self.serial_port,
|
|
45
|
+
"baudrate": self.baudrate,
|
|
46
|
+
"timeout": self.timeout,
|
|
47
|
+
},
|
|
48
|
+
"axes": {
|
|
49
|
+
axis_name: axis.__dict__() for axis_name, axis in self.axes.items()
|
|
50
|
+
},
|
|
51
|
+
"known_positions": self.known_positions,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@config.setter
|
|
55
|
+
def config(self, config: dict) -> None:
|
|
56
|
+
self.serial_port = config["connection"]["serial_port"]
|
|
57
|
+
self.baudrate = config["connection"]["baudrate"]
|
|
58
|
+
self.timeout = config["connection"]["timeout"]
|
|
59
|
+
|
|
60
|
+
self.axes = {}
|
|
61
|
+
for axis_name, axis_config in config["axes"].items():
|
|
62
|
+
self.add_axis(axis_name, **axis_config)
|
|
63
|
+
time.sleep(1)
|
|
64
|
+
|
|
65
|
+
# self.axes = {
|
|
66
|
+
# axis_name: StageAxis(controller=self, name=axis_name, **axis_config)
|
|
67
|
+
# for axis_name, axis_config in config["axes"].items()
|
|
68
|
+
# }
|
|
69
|
+
self.known_positions = config["known_positions"]
|
|
70
|
+
|
|
71
|
+
def connect(self) -> None:
|
|
72
|
+
self.api = StageAPI(
|
|
73
|
+
serial_port=self.serial_port,
|
|
74
|
+
baudrate=self.baudrate,
|
|
75
|
+
timeout=self.timeout,
|
|
76
|
+
)
|
|
77
|
+
self.api.connect()
|
|
78
|
+
self.api.get_stage_id()
|
|
79
|
+
|
|
80
|
+
# factory function to create a StageController instance from a configuration file
|
|
81
|
+
@staticmethod
|
|
82
|
+
def from_config(config_file: str | Path | dict) -> "StageController":
|
|
83
|
+
if isinstance(config_file, str):
|
|
84
|
+
config_file = Path(config_file)
|
|
85
|
+
|
|
86
|
+
if isinstance(config_file, Path):
|
|
87
|
+
config_file = Path(config_file)
|
|
88
|
+
# check file exists
|
|
89
|
+
if not config_file.is_file():
|
|
90
|
+
raise FileNotFoundError(f"Config file not found: {config_file}")
|
|
91
|
+
|
|
92
|
+
config_file.open("r")
|
|
93
|
+
with config_file.open("r") as file:
|
|
94
|
+
# yaml full load
|
|
95
|
+
stage_config = yaml.full_load(file)
|
|
96
|
+
elif isinstance(config_file, dict):
|
|
97
|
+
stage_config = config_file
|
|
98
|
+
else:
|
|
99
|
+
raise TypeError(f"Config type {config_file} is not supported")
|
|
100
|
+
|
|
101
|
+
ctrl = StageController(**stage_config["connection"])
|
|
102
|
+
ctrl.config = stage_config
|
|
103
|
+
|
|
104
|
+
# ctrl.ping_axes()
|
|
105
|
+
|
|
106
|
+
return ctrl
|
|
107
|
+
|
|
108
|
+
def save_config(self, config_file: str | Path, overwrite: bool = True) -> bool:
|
|
109
|
+
config_file = Path(config_file)
|
|
110
|
+
# check file exists
|
|
111
|
+
if config_file.exists() and not overwrite:
|
|
112
|
+
raise FileExistsError(f"Config file already exists: {config_file}")
|
|
113
|
+
|
|
114
|
+
with config_file.open("w") as file:
|
|
115
|
+
yaml.dump(self.config, file)
|
|
116
|
+
|
|
117
|
+
return config_file.exists()
|
|
118
|
+
|
|
119
|
+
def add_axis(self, axis_name: str, **axis_config) -> None:
|
|
120
|
+
if "name" in axis_config:
|
|
121
|
+
axis_name = axis_config.pop("name")
|
|
122
|
+
|
|
123
|
+
if axis_name in self.axes:
|
|
124
|
+
raise ValueError(f"Axis already exists: {axis_name}")
|
|
125
|
+
|
|
126
|
+
self.axes[axis_name] = StageAxis(api=self.api, name=axis_name, **axis_config)
|
|
127
|
+
info = self.axes[axis_name].get_info()
|
|
128
|
+
logging.debug(f"Controller: Added new axis {axis_name} ({info})")
|
|
129
|
+
|
|
130
|
+
return self.axes[axis_name]
|
|
131
|
+
|
|
132
|
+
def ping_axes(self) -> None:
|
|
133
|
+
for axis in self.axes.values():
|
|
134
|
+
axis.get_info()
|
|
135
|
+
|
|
136
|
+
def move_to_position(self, position: dict) -> None:
|
|
137
|
+
logging.debug(f"Controller: Move to position: {position}")
|
|
138
|
+
|
|
139
|
+
# lookup axis by name and move to position
|
|
140
|
+
position_by_axis_id = []
|
|
141
|
+
for axis_name in position:
|
|
142
|
+
axis_id = self.axes[axis_name].id
|
|
143
|
+
axis_target = position[axis_name]
|
|
144
|
+
logging.debug(f"Controller: Move to axis: {axis_id} -> {axis_target}")
|
|
145
|
+
if "position_raw" in axis_target:
|
|
146
|
+
axis_target_position = axis_target["position_raw"]
|
|
147
|
+
logging.debug(f"removing key position_raw from target {axis_target}")
|
|
148
|
+
else:
|
|
149
|
+
axis_target_position = axis_target
|
|
150
|
+
|
|
151
|
+
position_by_axis_id.append((axis_id, axis_target_position))
|
|
152
|
+
|
|
153
|
+
# position_by_axis_id = [
|
|
154
|
+
# (self.axes[axis_name].id, position[axis_name]["position_raw"])
|
|
155
|
+
# for axis_name in position
|
|
156
|
+
# ]
|
|
157
|
+
# # FIXME: should have to write position_raw here
|
|
158
|
+
|
|
159
|
+
return self.api.set_position_multiple(position_tuples=position_by_axis_id)
|
|
160
|
+
|
|
161
|
+
def move_to_known_position(self, position_name: str) -> None:
|
|
162
|
+
position = self.known_positions.get(position_name)
|
|
163
|
+
|
|
164
|
+
if position is None:
|
|
165
|
+
raise ValueError(f"Unknown position: {position_name}")
|
|
166
|
+
|
|
167
|
+
return self.move_to_position(position)
|
|
168
|
+
|
|
169
|
+
def save_as_known_position(self, position_name: str) -> None:
|
|
170
|
+
new_position = {}
|
|
171
|
+
for axis_name in self.axes:
|
|
172
|
+
# self.axes[axis_name].dict()
|
|
173
|
+
self.axes[axis_name].get_info()
|
|
174
|
+
axis_dict = self.axes[axis_name].__dict__()
|
|
175
|
+
new_position[axis_name] = {"position_raw": axis_dict["position_raw"]}
|
|
176
|
+
|
|
177
|
+
self.known_positions[position_name] = new_position
|
|
178
|
+
logging.debug(f"Controller: Save as known position: {new_position}")
|
|
179
|
+
|
|
180
|
+
def remove_known_position(self, position_name: str) -> None:
|
|
181
|
+
self.known_positions.pop(position_name)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from functools import partial
|
|
3
|
+
|
|
4
|
+
from one_axis_stage.controller import StageController
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MoveInterface:
|
|
8
|
+
""""""
|
|
9
|
+
|
|
10
|
+
controller: StageController
|
|
11
|
+
small_increment: int
|
|
12
|
+
large_increment: int
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
controller: StageController,
|
|
17
|
+
small_increment: int = 20,
|
|
18
|
+
large_increment: int = 40,
|
|
19
|
+
):
|
|
20
|
+
super().__init__()
|
|
21
|
+
self.controller = controller
|
|
22
|
+
self.small_increment = small_increment
|
|
23
|
+
self.large_increment = large_increment
|
|
24
|
+
|
|
25
|
+
for axis_name in self.controller.axes:
|
|
26
|
+
logging.info(f"Creating move methods for axis '{axis_name}'")
|
|
27
|
+
# slow
|
|
28
|
+
setattr(
|
|
29
|
+
self,
|
|
30
|
+
f"{axis_name}p",
|
|
31
|
+
partial(
|
|
32
|
+
self.move_axis_by_increment,
|
|
33
|
+
axis_name=axis_name,
|
|
34
|
+
direction_forward=True,
|
|
35
|
+
fast_mode=False,
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
setattr(
|
|
39
|
+
self,
|
|
40
|
+
f"{axis_name}m",
|
|
41
|
+
partial(
|
|
42
|
+
self.move_axis_by_increment,
|
|
43
|
+
axis_name=axis_name,
|
|
44
|
+
direction_forward=False,
|
|
45
|
+
fast_mode=False,
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
# fast
|
|
49
|
+
setattr(
|
|
50
|
+
self,
|
|
51
|
+
f"{axis_name}pp",
|
|
52
|
+
partial(
|
|
53
|
+
self.move_axis_by_increment,
|
|
54
|
+
axis_name=axis_name,
|
|
55
|
+
direction_forward=True,
|
|
56
|
+
fast_mode=True,
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
setattr(
|
|
60
|
+
self,
|
|
61
|
+
f"{axis_name}mm",
|
|
62
|
+
partial(
|
|
63
|
+
self.move_axis_by_increment,
|
|
64
|
+
axis_name=axis_name,
|
|
65
|
+
direction_forward=False,
|
|
66
|
+
fast_mode=True,
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def move_axis_by_increment(
|
|
71
|
+
self,
|
|
72
|
+
axis_name: str,
|
|
73
|
+
direction_forward: bool = True,
|
|
74
|
+
fast_mode: bool = False,
|
|
75
|
+
):
|
|
76
|
+
axis = self.controller.axes.get(axis_name)
|
|
77
|
+
if axis:
|
|
78
|
+
increment_speed = (
|
|
79
|
+
self.small_increment if not fast_mode else self.large_increment
|
|
80
|
+
)
|
|
81
|
+
increment = increment_speed if direction_forward else -increment_speed
|
|
82
|
+
new_position = axis.position_raw + increment
|
|
83
|
+
logging.info(
|
|
84
|
+
f"Moving axis '{axis.name}' {axis.position_raw}->{new_position} ({increment})"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# try to move, but ignore errors, only report
|
|
88
|
+
try:
|
|
89
|
+
axis.set_position(new_position)
|
|
90
|
+
except AssertionError as e:
|
|
91
|
+
logging.error(e)
|
|
92
|
+
|
|
93
|
+
else:
|
|
94
|
+
raise ValueError(f"Axis '{axis_name}' not found in controller")
|
one_axis_stage/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: one-axis-stage
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Hardware design and software for modular, low-cost one-axis stages
|
|
5
|
+
Project-URL: Homepage, https://github.com/murineshiftwork/one-axis-stage
|
|
6
|
+
Project-URL: Documentation, https://murineshiftwork.github.io/one-axis-stage/
|
|
7
|
+
Project-URL: Issue Tracker, https://github.com/murineshiftwork/one-axis-stage/issues
|
|
8
|
+
Author-email: "Lars B. Rollik" <lars@rollik.me>
|
|
9
|
+
License: BSD 3-Clause License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2021-present, Lars B. Rollik
|
|
12
|
+
All rights reserved.
|
|
13
|
+
|
|
14
|
+
Redistribution and use in source and binary forms, with or without
|
|
15
|
+
modification, are permitted provided that the following conditions are met:
|
|
16
|
+
|
|
17
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
18
|
+
list of conditions and the following disclaimer.
|
|
19
|
+
|
|
20
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
21
|
+
this list of conditions and the following disclaimer in the documentation
|
|
22
|
+
and/or other materials provided with the distribution.
|
|
23
|
+
|
|
24
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
25
|
+
contributors may be used to endorse or promote products derived from
|
|
26
|
+
this software without specific prior written permission.
|
|
27
|
+
|
|
28
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
29
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
30
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
31
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
32
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
33
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
34
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
35
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
36
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
37
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
38
|
+
License-File: LICENSE
|
|
39
|
+
Requires-Python: >=3.10
|
|
40
|
+
Requires-Dist: pyserial
|
|
41
|
+
Requires-Dist: pyyaml
|
|
42
|
+
Provides-Extra: dev
|
|
43
|
+
Requires-Dist: commitizen; extra == 'dev'
|
|
44
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
45
|
+
Requires-Dist: pre-commit; extra == 'dev'
|
|
46
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
47
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
48
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
49
|
+
Requires-Dist: types-pyyaml; extra == 'dev'
|
|
50
|
+
Provides-Extra: docs
|
|
51
|
+
Requires-Dist: mkdocs-material; extra == 'docs'
|
|
52
|
+
Description-Content-Type: text/markdown
|
|
53
|
+
|
|
54
|
+
[//]: # (Links)
|
|
55
|
+
[Github-flavored markdown]: https://github.github.com/gfm
|
|
56
|
+
|
|
57
|
+
[manifest]: https://packaging.python.org/en/latest/guides/using-manifest-in
|
|
58
|
+
[packaging]: https://packaging.python.org/en/latest/tutorials/packaging-projects
|
|
59
|
+
[setup.cfg]: https://setuptools.pypa.io/en/latest/userguide/declarative_config.html
|
|
60
|
+
|
|
61
|
+
[bump2version]: (https://github.com/c4urself/bump2version
|
|
62
|
+
[pre-commit]: https://pre-commit.com
|
|
63
|
+
|
|
64
|
+
[//]: # ([black]: https://github.com/psf/black)
|
|
65
|
+
[ruff]: https://docs.astral.sh/ruff
|
|
66
|
+
[mypy]: https://mypy.readthedocs.io
|
|
67
|
+
|
|
68
|
+
[pypi]: pypi.org
|
|
69
|
+
[test.pypi]: test.pypi.org
|
|
70
|
+
|
|
71
|
+
[Zenodo]: https://zenodo.org
|
|
72
|
+
|
|
73
|
+
[contribution guidelines]: https://github.com/larsrollik/one-axis-stage/blob/main/CONTRIBUTING.md
|
|
74
|
+
[issues]: https://github.com/larsrollik/one-axis-stage/issues
|
|
75
|
+
[BSD 3-Clause License]: https://github.com/larsrollik/one-axis-stage/blob/main/LICENSE
|
|
76
|
+
[Github]: https://github.com/larsrollik/one-axis-stage/settings/secrets/actions/new
|
|
77
|
+
[release]: https://github.com/larsrollik/one-axis-stage/releases/new
|
|
78
|
+
|
|
79
|
+
[//]: # (Badges)
|
|
80
|
+
|
|
81
|
+
[](https://github.com/larsrollik/one-axis-stage/blob/main/CONTRIBUTING.md)
|
|
82
|
+

|
|
83
|
+
[](https://github.com/pre-commit/pre-commit)
|
|
84
|
+
[](https://github.com/larsrollik/one-axis-stage)
|
|
85
|
+
|
|
86
|
+
[//]: # ([](https://pypi.org/project/one-axis-stage))
|
|
87
|
+
[//]: # ([](https://pypi.org/project/one-axis-stage))
|
|
88
|
+
|
|
89
|
+
# one-axis-stage
|
|
90
|
+
Hardware design and software for modular, low-cost one-axis stages
|
|
91
|
+
---
|
|
92
|
+
**Version: "0.0.1"**
|
|
93
|
+
|
|
94
|
+
```-> TODO: add image```
|
|
95
|
+
|
|
96
|
+
## Hardware build
|
|
97
|
+
|
|
98
|
+
### Parts list
|
|
99
|
+
|
|
100
|
+
##### Commercially available parts
|
|
101
|
+
|
|
102
|
+
| Part name | Product code | Amount (#) | Cost per part (GBP) | Total cost (GBP) | |
|
|
103
|
+
|-------------------------------|-----------------------|------------|---------------------|------------------|------------------------------------------------------------------------------------------------------|
|
|
104
|
+
| Arduino Mega 2560 | | 1 | 20 | | https://docs.arduino.cc/hardware/mega-2560 |
|
|
105
|
+
| Dynamixel Arduino shield | | 1 | 20 | | https://emanual.robotis.com/docs/en/parts/interface/dynamixel_shield/ |
|
|
106
|
+
| Dynamixel XL-320 motor | XL-320 | 1+ | 20 | | https://emanual.robotis.com/docs/en/dxl/x/xl320/ |
|
|
107
|
+
| USB to TTL adapter | LN-101 or MIKROE-3063 | 1 | 14 / 13 | | https://emanual.robotis.com/docs/en/parts/interface/ln-101 / https://www.mikroe.com/usb-uart-3-click |
|
|
108
|
+
| USB-B to other USB (computer) | | 1 | | | |
|
|
109
|
+
| Linear slide 26mm range | BSP1035SL | 2+ | 26 | | https://uk.rs-online.com/web/p/linear-slides/0749301 |
|
|
110
|
+
| | | | | | |
|
|
111
|
+
|
|
112
|
+
##### 3D-printed parts
|
|
113
|
+
|
|
114
|
+
| Part | Count |
|
|
115
|
+
|------|-------|
|
|
116
|
+
| | |
|
|
117
|
+
| | |
|
|
118
|
+
| | |
|
|
119
|
+
|
|
120
|
+
### Assembly instructions
|
|
121
|
+
|
|
122
|
+
```-> TODO: add info on how to assemble the hardware```
|
|
123
|
+
|
|
124
|
+
## Software controller
|
|
125
|
+
|
|
126
|
+
### Example usage
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from one_axis_stage import StageController
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
## Contributing
|
|
135
|
+
Contributions are very welcome!
|
|
136
|
+
Please see the [contribution guidelines] or check out the [issues]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
This software is released under the **[BSD 3-Clause License]**
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
one_axis_stage/__init__.py,sha256=Dq1imqtnE5ooaYlUokYrmDxk8ypBe-Fzp_faL8blnv8,503
|
|
2
|
+
one_axis_stage/api.py,sha256=l_1lYoQUgUsJo4J-1hYx8NpWmpgZ-03I-oyTzOtI4PQ,5759
|
|
3
|
+
one_axis_stage/axis.py,sha256=CSnU1XmeaDX-dug8XKDnQ3yfad_q_zpwC_l9gXKmW7I,3497
|
|
4
|
+
one_axis_stage/connection.py,sha256=ecVqqdS9zDbDXnmgAVSwU_jhH9KX_tyRYwnPiJ1cpQc,4891
|
|
5
|
+
one_axis_stage/controller.py,sha256=V9wRqtCTJ-T-KfhXbPJSRIefxTyjHHwBfTFdwFRnxdE,6170
|
|
6
|
+
one_axis_stage/interface.py,sha256=0fLf8q9-RTH9PHzF0cc9XETuRuhCEwMrojcoQxD84pM,2812
|
|
7
|
+
one_axis_stage/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
one_axis_stage-0.0.2.dist-info/METADATA,sha256=iFZQVwdw8Eupa_1gY_j69zwc0pwxPea4uQ5DASgBryA,7208
|
|
9
|
+
one_axis_stage-0.0.2.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
10
|
+
one_axis_stage-0.0.2.dist-info/licenses/LICENSE,sha256=mup8CL1NS8uJb0HhKkmUbOapCMXvL_Vf5CG2VsqTnjM,1530
|
|
11
|
+
one_axis_stage-0.0.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2021-present, Lars B. Rollik
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from
|
|
18
|
+
this software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|