Skip to content

Commit fb5e48f

Browse files
authored
Merge pull request #144 from openUC2/mergemaster
Mergemaster
2 parents 0a9b35e + f87b769 commit fb5e48f

4 files changed

Lines changed: 183 additions & 16 deletions

File tree

uc2rest/UC2Client.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,9 @@ class UC2Client(object):
4747
is_serial = False
4848
BAUDRATE = 115200
4949

50-
def __init__(self, host=None, port=31950, serialport=None, identity="UC2_Feather", baudrate=BAUDRATE,
50+
def __init__(self, host=None, port=31950, serialport=None, identity="UC2_Feather", baudrate=BAUDRATE,
5151
NLeds=64, SerialManager=None, DEBUG=False, logger=None, skipFirmwareCheck=False,
52-
isPyScript=False):
52+
isPyScript=False, device_id=None, requireMaster=False):
5353
'''
5454
This client connects to the UC2-REST microcontroller that can be found here
5555
https://github.com/openUC2/UC2-REST
@@ -80,7 +80,7 @@ def __init__(self, host=None, port=31950, serialport=None, identity="UC2_Feather
8080
# initialize communication channel (serial only)
8181
if serialport is not None:
8282
# use USB connection
83-
self.serial = Serial(serialport, baudrate, parent=self, identity=identity, DEBUG=DEBUG, skipFirmwareCheck=skipFirmwareCheck)
83+
self.serial = Serial(serialport, baudrate, parent=self, identity=identity, DEBUG=DEBUG, skipFirmwareCheck=skipFirmwareCheck, device_id=device_id, requireMaster=requireMaster)
8484
self.is_serial = True
8585
self.is_connected = self.serial.is_connected
8686
self.serial.DEBUG = DEBUG

uc2rest/motor.py

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -613,10 +613,15 @@ def move_stepper(self, steps=(0,0,0,0), speed=(1000,1000,1000,1000), is_absolute
613613
if isAbsoluteArray[iMotor]:
614614
# Compare current position (physical) with target (physical, already includes offset)
615615
self.currentDirection[iMotor] = 1 if (self.currentPosition[iMotor] > targetPositionPhysical[iMotor]) else -1
616-
# Calculate distance to travel in HARDWARE STEPS:
617-
# Current position (physical) -> convert to steps, then subtract target (already in steps)
618-
currentPosition_steps = self.currentPosition[iMotor] / stepSizes[iMotor]
619-
absoluteDistances_steps[iMotor] = abs(currentPosition_steps - steps[iMotor])
616+
# Travel distance for the time estimate = |current - target| in
617+
# PHYSICAL units, converted to hardware steps. Computing it in
618+
# physical units keeps it direction-agnostic: the hardware `steps`
619+
# target carries the per-axis direction sign, so subtracting it
620+
# from an unsigned current-in-steps produced a *sum* (not a
621+
# difference) on inverted axes, hugely inflating the estimate.
622+
absoluteDistances_steps[iMotor] = abs(
623+
self.currentPosition[iMotor] - targetPositionPhysical[iMotor]
624+
) / stepSizes[iMotor]
620625
else:
621626
self.currentDirection[iMotor] = np.sign(steps[iMotor])
622627
# For relative motion, steps[iMotor] is already the distance in hardware steps
@@ -629,19 +634,20 @@ def move_stepper(self, steps=(0,0,0,0), speed=(1000,1000,1000,1000), is_absolute
629634
if not isAbsoluteArray[iMotor]:
630635
absoluteDistances_steps[iMotor] = abs(steps[iMotor])
631636

632-
# Convert speed and acceleration from physical units to steps/second
637+
# Speed and acceleration are already in firmware step units (the same raw
638+
# values sent to the device), and absoluteDistances_steps is in hardware
639+
# steps too, so the time estimate is unit-consistent WITHOUT any stepSize
640+
# division — dividing here would desync it from the distance and break the
641+
# estimate. Just take magnitudes.
633642
speed_steps = np.zeros(4)
634643
acceleration_steps = np.zeros(4)
635644
for iMotor in range(4):
636645
if speed[iMotor] != 0:
637-
# Speed: µm/s -> steps/s => divide by stepSize (µm/step)
638-
speed_steps[iMotor] = abs(speed[iMotor]) # TODO: This is actually given in steps/s / stepSizes[iMotor]
646+
speed_steps[iMotor] = abs(speed[iMotor])
639647
if acceleration[iMotor] is not None and acceleration[iMotor] != 0:
640-
# Acceleration: µm/s² -> steps/s² => divide by stepSize
641-
acceleration_steps[iMotor] = abs(acceleration[iMotor]) # TODO: This is actually given in steps/s / stepSizes[iMotor]
648+
acceleration_steps[iMotor] = abs(acceleration[iMotor])
642649
else:
643-
# Default acceleration in steps/s²
644-
acceleration_steps[iMotor] = 20000 # This should also be converted, but we use a safe default
650+
acceleration_steps[iMotor] = 20000 # safe default (firmware steps/s^2)
645651

646652
# Calculate travel time using HARDWARE STEPS and converted speed/acceleration
647653
# Find the axis that will take the longest (limits overall movement time)
@@ -696,8 +702,10 @@ def move_stepper(self, steps=(0,0,0,0), speed=(1000,1000,1000,1000), is_absolute
696702
"redu": int(is_reduced)}
697703
if acceleration[iMotor] is not None:
698704
motorProp["accel"] = int(acceleration[iMotor])
705+
motorProp["acceleration"] = int(acceleration[iMotor])
699706
else:
700707
motorProp["accel"] = self.DEFAULT_ACCELERATION
708+
motorProp["acceleleration"] = self.DEFAULT_ACCELERATION
701709
motorPropList.append(motorProp)
702710
if len(motorPropList)==0:
703711
return "{'return':-1}"
@@ -1243,6 +1251,44 @@ def set_tmc_parameters(self, axis=0, msteps=None, rms_current=None, stall_value=
12431251
r = self._parent.post_json(path, payload, timeout=timeout)
12441252
return r
12451253

1254+
def get_tmc_parameters(self, axis=0, timeout=1):
1255+
''' Read the TMC parameters for a specific axis back from the device.
1256+
1257+
Sends {"task":"/tmc_get", "axis":<n>} and parses the response. Returns a
1258+
dict with msteps/rms_current/sgthrs/semin/semax/blank_time/toff, or None
1259+
if the firmware does not implement TMC readback (older firmwares only
1260+
accept /tmc_act). Callers should fall back to their last-applied values
1261+
in that case.
1262+
'''
1263+
if type(axis) == str:
1264+
axis = self.xyztTo1230(axis)
1265+
path = "/tmc_get"
1266+
payload = {"task": path}
1267+
if axis is not None:
1268+
payload["axis"] = axis
1269+
try:
1270+
r = self._parent.post_json(path, payload, timeout=timeout)
1271+
if isinstance(r, list):
1272+
r = r[0] if r else {}
1273+
if not isinstance(r, dict):
1274+
return None
1275+
# firmware may nest the values under "tmc" or return them flat
1276+
tmc = r.get("tmc", r)
1277+
if not isinstance(tmc, dict):
1278+
return None
1279+
keys = ("msteps", "rms_current", "sgthrs", "semin", "semax", "blank_time", "toff")
1280+
if not any(k in tmc for k in keys):
1281+
# nothing TMC-shaped came back -> readback unsupported
1282+
return None
1283+
return {k: tmc[k] for k in keys if k in tmc}
1284+
except Exception as e:
1285+
self._parent.logger.debug(f"get_tmc_parameters failed: {e}")
1286+
return None
1287+
1288+
# camelCase alias used by the ImSwitch ESP32StageManager
1289+
def getTMCSettings(self, axis=0, timeout=1):
1290+
return self.get_tmc_parameters(axis=axis, timeout=timeout)
1291+
12461292
def set_hard_limits(self, axis=1, enabled=True, polarity=0, timeout=1):
12471293
'''
12481294
Configure hard limits (emergency stop) for a motor axis.

uc2rest/mserial.py

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ class SerialException(Exception):
1616
T_SERIAL_WARMUP = 2.5
1717
class Serial:
1818
def __init__(self, port, baudrate=115200, timeout=5,
19-
identity="UC2_Feather", parent=None, DEBUG=False,
20-
skipFirmwareCheck=False):
19+
identity="UC2_Feather", parent=None, DEBUG=False,
20+
skipFirmwareCheck=False, device_id=None, requireMaster=False):
2121

2222
self.serialdevice = None
2323
self.serialport = port
@@ -26,6 +26,15 @@ def __init__(self, port, baudrate=115200, timeout=5,
2626
self._parent = parent
2727
self.manufacturer = ""
2828
self.skipFirmwareCheck = skipFirmwareCheck
29+
# Connect only to a specific board / only to the CANopen master.
30+
# device_id pins a physical board by its USB serial number (or a
31+
# substring of the port path / hwid). requireMaster makes auto-discovery
32+
# skip any board whose firmware does NOT report a "*_master" pindef, so a
33+
# motor/slave board (ESP32-S3, native USB) is never picked accidentally.
34+
self.device_id = device_id
35+
self.requireMaster = requireMaster
36+
# identity of the board we actually connected to (filled by checkFirmware)
37+
self.firmware_info = {}
2938
if self._parent is None:
3039
import logging
3140
self._logger = logging.getLogger(__name__)
@@ -160,6 +169,64 @@ def openDevice(self, port=None, baud_rate=115200):
160169

161170
return ser
162171

172+
def _portMatchesDeviceId(self, port):
173+
'''True if `port` matches the configured device_id, or if no device_id
174+
is set (then everything matches). The device_id is compared as a
175+
case-insensitive substring against the USB serial number, the port path
176+
and the hwid, so the user can pin a board by whichever is stable on
177+
their OS.'''
178+
if not self.device_id:
179+
return True
180+
did = str(self.device_id).lower()
181+
candidates = [getattr(port, "serial_number", None),
182+
getattr(port, "device", None),
183+
getattr(port, "hwid", None)]
184+
return any(c and did in str(c).lower() for c in candidates)
185+
186+
def _probeDeviceIdentity(self, ser, timeout=2):
187+
'''Send /state_get and parse the firmware identity block into
188+
self.firmware_info: {name, version, date, author, pindef, isMaster}.
189+
Used by requireMaster to reject motor/slave boards. Best-effort: returns
190+
an empty dict (and leaves firmware_info empty) if nothing parses.'''
191+
info = {}
192+
try:
193+
self._write(ser, {"task": "/state_get"})
194+
ser.write(b'\n')
195+
buffer = ""
196+
reading_json = False
197+
t0 = time.time()
198+
while time.time() - t0 < timeout:
199+
raw = self._read(ser)
200+
try:
201+
line = raw.decode('utf-8').strip()
202+
except Exception:
203+
continue
204+
if line == "":
205+
continue
206+
if line.find("++") >= 0:
207+
reading_json = True
208+
continue
209+
if reading_json and line.find("--") >= 0:
210+
break
211+
if reading_json:
212+
buffer += line
213+
if buffer:
214+
data = json.loads(buffer)
215+
state = data.get("state", data) if isinstance(data, dict) else {}
216+
pindef = state.get("pindef", "")
217+
info = {
218+
"name": state.get("identifier_name", ""),
219+
"version": state.get("identifier_id", ""),
220+
"date": state.get("identifier_date", ""),
221+
"author": state.get("identifier_author", ""),
222+
"pindef": pindef,
223+
"isMaster": "master" in str(pindef).lower(),
224+
}
225+
except Exception as e:
226+
self._logger.debug(f"_probeDeviceIdentity failed: {e}")
227+
self.firmware_info = info
228+
return info
229+
163230
def findCorrectSerialDevice(self):
164231
'''
165232
This function tries to find the correct serial device from the list of available ports
@@ -183,6 +250,9 @@ def findCorrectSerialDevice(self):
183250
descriptions_to_check = ["CH340", "CP2102", "USB2.0-Serial", "USB-Serial"]
184251

185252
for port in _available_ports:
253+
# If a specific board was requested, ignore everything else.
254+
if not self._portMatchesDeviceId(port):
255+
continue
186256
if any(port.device.startswith(p) for p in ports_to_check) or \
187257
any(port.description.startswith(d) for d in descriptions_to_check):
188258
if current_os.startswith("darwin") and port.device.startswith("/dev/cu.usbserial-"):
@@ -217,6 +287,22 @@ def tryToConnect(self, port, baudrate=None):
217287
#time.sleep(T_SERIAL_WARMUP)
218288
self._freeSerialBuffer(self.serialdevice, timeout=2, timeMinimum=1)
219289
if self.skipFirmwareCheck or self.checkFirmware(self.serialdevice):
290+
# When only the master may be used, read the firmware identity and
291+
# reject boards that are not a CANopen master (e.g. ESP32-S3 motor
292+
# boards on native USB that also speak the UC2 protocol).
293+
if self.requireMaster:
294+
self._probeDeviceIdentity(self.serialdevice)
295+
if not self.firmware_info.get("isMaster", False):
296+
self._logger.debug(
297+
f"Skipping non-master board on {getattr(port, 'device', '?')} "
298+
f"(pindef={self.firmware_info.get('pindef', '?')})"
299+
)
300+
try:
301+
self.serialdevice.close()
302+
except Exception:
303+
pass
304+
self.is_connected = False
305+
return False
220306
self.is_connected = True
221307
self.NumberRetryReconnect = 0
222308
return True

uc2rest/state.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,41 @@ def get_state(self, timeout=3):
137137
r = self._parent.get_json(path, timeout=timeout)
138138
return r
139139

140+
def get_firmware_info(self, timeout=3):
141+
'''
142+
Return the firmware identity of the USB-connected ESP32 as a flat dict:
143+
{"name", "version", "date", "author", "pindef", "isMaster"}.
144+
145+
Parsed from /state_get, e.g.
146+
{"state":{"identifier_name":"UC2_Feather","identifier_id":"V2.0",
147+
"identifier_date":"Jun 17 2026 07:17:22","identifier_author":"BD",
148+
"pindef":"UC2_canopen_master", ...},"qid":0}
149+
The build date and pindef are the fields that matter for telling boards
150+
apart. Returns an empty dict if nothing could be parsed.
151+
'''
152+
r = self.get_state(timeout=timeout)
153+
try:
154+
if isinstance(r, list):
155+
r = r[0] if r else {}
156+
state = r.get("state", r) if isinstance(r, dict) else {}
157+
pindef = state.get("pindef", "")
158+
return {
159+
"name": state.get("identifier_name", ""),
160+
"version": state.get("identifier_id", ""),
161+
"date": state.get("identifier_date", ""),
162+
"author": state.get("identifier_author", ""),
163+
"pindef": pindef,
164+
"isMaster": "master" in str(pindef).lower(),
165+
}
166+
except Exception as e:
167+
self._parent.logger.debug(f"get_firmware_info failed: {e}")
168+
return {}
169+
170+
def is_master(self, timeout=1):
171+
'''True if the connected board reports a CANopen-master pindef.'''
172+
info = self.get_firmware_info(timeout=timeout)
173+
return bool(info.get("isMaster", False))
174+
140175
def delay(self, delay=1, getReturn=True):
141176
path = "/state_act"
142177
payload = {

0 commit comments

Comments
 (0)