UC2-REST 0.2.0.43__tar.gz → 0.2.0.44__tar.gz

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.
Files changed (52) hide show
  1. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/PKG-INFO +1 -1
  2. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/UC2_REST.egg-info/PKG-INFO +1 -1
  3. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/UC2_REST.egg-info/SOURCES.txt +1 -0
  4. uc2_rest-0.2.0.44/tests/test_motor_get_reply.py +92 -0
  5. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/UC2Client.py +5 -2
  6. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/__version__.py +1 -1
  7. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/motor.py +39 -8
  8. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/mserial.py +20 -5
  9. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/LICENSE +0 -0
  10. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/README.md +0 -0
  11. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/UC2_REST.egg-info/dependency_links.txt +0 -0
  12. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/UC2_REST.egg-info/not-zip-safe +0 -0
  13. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/UC2_REST.egg-info/requires.txt +0 -0
  14. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/UC2_REST.egg-info/top_level.txt +0 -0
  15. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/setup.cfg +0 -0
  16. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/setup.py +0 -0
  17. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/tests/test_canota_reconnect.py +0 -0
  18. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/tests/test_motor_axes.py +0 -0
  19. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/tests/test_stage_scan.py +0 -0
  20. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/MockSerial.py +0 -0
  21. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/__init__.py +0 -0
  22. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/aio.py +0 -0
  23. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/analog.py +0 -0
  24. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/camera.py +0 -0
  25. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/camera_trigger.py +0 -0
  26. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/can.py +0 -0
  27. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/canota.py +0 -0
  28. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/cmdrecorder.py +0 -0
  29. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/digitalin.py +0 -0
  30. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/digitalout.py +0 -0
  31. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/fan.py +0 -0
  32. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/galvo.py +0 -0
  33. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/gpio.py +0 -0
  34. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/gripper.py +0 -0
  35. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/home.py +0 -0
  36. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/i2c.py +0 -0
  37. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/laser.py +0 -0
  38. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/lcddisplay.py +0 -0
  39. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/ledmatrix.py +0 -0
  40. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/logger.py +0 -0
  41. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/message.py +0 -0
  42. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/modules.py +0 -0
  43. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/motor_config.py +0 -0
  44. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/objective.py +0 -0
  45. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/pid.py +0 -0
  46. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/ptz.py +0 -0
  47. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/rotator.py +0 -0
  48. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/slm.py +0 -0
  49. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/state.py +0 -0
  50. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/temperature.py +0 -0
  51. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/utils.py +0 -0
  52. {uc2_rest-0.2.0.43 → uc2_rest-0.2.0.44}/uc2rest/wifi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: UC2-REST
3
- Version: 0.2.0.43
3
+ Version: 0.2.0.44
4
4
  Summary: This pacage will help you to drive the ESP32-driven microscopy control modules from UC2
5
5
  Home-page: https://github.com/openUC2/UC2-REST
6
6
  Author: Benedict Diederich
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: UC2-REST
3
- Version: 0.2.0.43
3
+ Version: 0.2.0.44
4
4
  Summary: This pacage will help you to drive the ESP32-driven microscopy control modules from UC2
5
5
  Home-page: https://github.com/openUC2/UC2-REST
6
6
  Author: Benedict Diederich
@@ -9,6 +9,7 @@ UC2_REST.egg-info/requires.txt
9
9
  UC2_REST.egg-info/top_level.txt
10
10
  tests/test_canota_reconnect.py
11
11
  tests/test_motor_axes.py
12
+ tests/test_motor_get_reply.py
12
13
  tests/test_stage_scan.py
13
14
  uc2rest/MockSerial.py
14
15
  uc2rest/UC2Client.py
@@ -0,0 +1,92 @@
1
+ """/motor_get answers vs. position pushes that share their qid (no hardware).
2
+
3
+ The firmware tags a motor's position push ({"steppers":[{.., "isDone": 1}]}) with the qid of
4
+ that motor's last move; uc2rest's qids wrap after 254 commands, so such a frame can carry the
5
+ qid of a later /motor_get. Taken as the answer, every other axis read 0 (ImSwitch FRAME
6
+ calibration, 2026-11-12: "X reads 728.2 after a move to 19394.5" = device X 0).
7
+
8
+ Run: python -m unittest tests.test_motor_get_reply (from the repo root)
9
+ """
10
+ import threading
11
+ import time
12
+ import unittest
13
+ from unittest import mock
14
+
15
+ import numpy as np
16
+
17
+ from uc2rest.motor import Motor
18
+ from uc2rest.mserial import Serial
19
+
20
+ PUSH = {"steppers": [{"stepperid": 0, "position": -29000, "isDone": 1}], "qid": 7}
21
+ REPLY = {"motor": {"steppers": [{"stepperid": i, "position": p} for i, p in enumerate((-29000, 19000, 60000, 5900))]},
22
+ "qid": 7}
23
+
24
+
25
+ class _Parent:
26
+ def __init__(self, frames):
27
+ self.serial = mock.Mock()
28
+ self.logger = mock.Mock()
29
+ self.frames = frames
30
+
31
+ def post_json(self, path, payload, accept=None, **kwargs):
32
+ # like Serial.sendMessage: with accept, the matching frame first
33
+ if accept is not None:
34
+ hit = next((f for f in self.frames if accept(f)), None)
35
+ return "communication interrupted by timeout" if hit is None else [hit] + [f for f in self.frames if f is not hit]
36
+ return list(self.frames)
37
+
38
+
39
+ class MotorGetReplyTest(unittest.TestCase):
40
+ def motor(self, frames):
41
+ parent = _Parent(frames)
42
+ m = parent.motor = Motor(parent=parent)
43
+ for ax in "AXYZ":
44
+ m.setup_motor(ax, minPos=-1e9, maxPos=1e9, stepSize=1, backlash=0)
45
+ return m
46
+
47
+ def test_push_with_our_qid_is_not_the_answer(self):
48
+ p = self.motor([PUSH, REPLY]).get_position()
49
+ self.assertEqual(list(p[:4]), [-29000, 19000, 60000, 5900])
50
+
51
+ def test_no_answer_reports_missing(self):
52
+ m = self.motor([PUSH])
53
+ self.assertTrue(np.isnan(m.get_position(missing=np.nan)[:4]).all())
54
+ self.assertEqual(list(m.get_position()[:4]), [0, 0, 0, 0]) # default: as before
55
+ self.assertEqual(m._read_firmware_positions(), {})
56
+
57
+ def test_filtered_can_reply_is_an_answer(self):
58
+ can = {"steppers": [{"stepperid": 4, "position": 3}, {"stepperid": 5, "isDone": -1}], "qid": 2}
59
+ self.assertTrue(Motor._is_motor_get_reply(can, ids=[4, 5]))
60
+ self.assertFalse(Motor._is_motor_get_reply(can)) # a bare request wants {"motor": ...}
61
+ self.assertFalse(Motor._is_motor_get_reply(PUSH, ids=[0, 1]))
62
+ self.assertFalse(Motor._is_motor_get_reply(PUSH))
63
+ self.assertFalse(Motor._is_motor_get_reply("communication interrupted"))
64
+
65
+
66
+ class SendMessageAcceptTest(unittest.TestCase):
67
+ """Serial.sendMessage waits past frames that share the qid but fail accept."""
68
+
69
+ def test_waits_for_the_accepted_frame(self):
70
+ s = Serial.__new__(Serial)
71
+ s.lock, s.responses, s.commands = threading.Lock(), {}, {}
72
+ s.identifier_counter, s.running, s.is_connected = 0, True, True
73
+ s.resetLastCommand, s.use_qid_done, s.DEBUG, s.manufacturer = False, False, False, "test"
74
+ s.timeReturnReceived, s._logger, s.cmdWriteCallBackFct = 5, mock.Mock(), None
75
+
76
+ def write(dev, payload):
77
+ qid = s.identifier_counter
78
+ def arrive():
79
+ for f in ({**PUSH, "qid": qid}, {**REPLY, "qid": qid}):
80
+ time.sleep(0.05)
81
+ with s.lock:
82
+ s.responses.setdefault(qid, []).append(f)
83
+ threading.Thread(target=arrive, daemon=True).start()
84
+ s._write, s.serialdevice = write, None
85
+ r = s.sendMessage({"task": "/motor_get", "position": True}, nResponses=1, timeout=2,
86
+ accept=Motor._is_motor_get_reply)
87
+ self.assertIn("motor", r[0])
88
+ self.assertEqual(len(r), 2)
89
+
90
+
91
+ if __name__ == "__main__":
92
+ unittest.main()
@@ -177,10 +177,13 @@ class UC2Client(object):
177
177
  # initialize module controller
178
178
  self.modules = Modules(parent=self)
179
179
 
180
- def post_json(self, path, payload, getReturn=True, nResponses=1, timeout=1):
180
+ def post_json(self, path, payload, getReturn=True, nResponses=1, timeout=1, accept=None):
181
181
  if timeout <=0:
182
182
  getReturn = False
183
- return self.serial.post_json(path, payload, getReturn=getReturn, nResponses=nResponses, timeout=timeout)
183
+ if accept is None: # serial implementations without the accept argument keep working
184
+ return self.serial.post_json(path, payload, getReturn=getReturn, nResponses=nResponses, timeout=timeout)
185
+ return self.serial.post_json(path, payload, getReturn=getReturn, nResponses=nResponses, timeout=timeout,
186
+ accept=accept)
184
187
 
185
188
  def get_json(self, path, getReturn=True, timeout=1):
186
189
  if self.is_serial or self.isPyScript:
@@ -6,7 +6,7 @@ __version__.py
6
6
 
7
7
  __title__ = 'UC2-REST'
8
8
  __description__ = 'This pacage will help you to drive the ESP32-driven microscopy control modules from UC2'
9
- __version__ = "v0.2.0.43"
9
+ __version__ = "v0.2.0.44"
10
10
  __author__ = 'Benedict Diederich'
11
11
  __author_email__ = 'benedictdied@gmail.com'
12
12
  __license__ = 'GPL v3'
@@ -901,12 +901,25 @@ class Motor(object):
901
901
 
902
902
  return r
903
903
 
904
+ def _post_motor_get(self, payload, timeout, ids=None):
905
+ '''Send a /motor_get and return its answer (a dict), or {} when none came: frames that
906
+ only share its qid (position pushes) are skipped, see _is_motor_get_reply.'''
907
+ accept = lambda x: self._is_motor_get_reply(x, ids)
908
+ try:
909
+ r = self._parent.post_json("/motor_get", payload, getReturn=True, nResponses=1, timeout=timeout,
910
+ accept=accept)
911
+ except TypeError: # a parent without the accept argument: take the first frame, but check it
912
+ r = self._parent.post_json("/motor_get", payload, getReturn=True, nResponses=1, timeout=timeout)
913
+ if isinstance(r, dict):
914
+ r = [r]
915
+ if not isinstance(r, list): # "communication interrupted by timeout ...", "Failed to Send"
916
+ return {}
917
+ return next((x for x in r if accept(x)), {})
918
+
904
919
  def _read_firmware_positions(self, timeout=1):
905
920
  '''{stepperid: physical position} from /motor_get, or {} if no reply.'''
906
921
  stepSizes = np.array((self.stepSizeA, self.stepSizeX, self.stepSizeY, self.stepSizeZ))
907
- r = self._parent.post_json("/motor_get", {"task": "/motor_get", "position": True},
908
- getReturn=True, nResponses=1, timeout=timeout)
909
- reply = r[0] if isinstance(r, list) and r and isinstance(r[0], dict) else {}
922
+ reply = self._post_motor_get({"task": "/motor_get", "position": True}, timeout)
910
923
  return {st["stepperid"]: st["position"] * stepSizes[st["stepperid"]] * self.direction[st["stepperid"]]
911
924
  for st in reply.get("motor", {}).get("steppers", [])
912
925
  if "position" in st and 0 <= st["stepperid"] < 4}
@@ -1039,9 +1052,27 @@ class Motor(object):
1039
1052
  r = self._parent.post_json(path, payload)
1040
1053
  return r
1041
1054
 
1042
- def get_position(self, axis=None, timeout=1):
1055
+ @staticmethod
1056
+ def _is_motor_get_reply(r, ids=None):
1057
+ '''A /motor_get answer, not a position push of one motor ({"steppers":[{..,"isDone":..}]}),
1058
+ which the firmware tags with the qid of that motor's last move - after the 254-command
1059
+ qid wrap that can be ours. Taken as the answer it reported every other axis as 0
1060
+ (ImSwitch FRAME calibration 2026-11-12: "X reads 728.2 after a move to 19394.5").
1061
+ Answers: {"motor":{"steppers":[...]}}; for a request filtered to stepper ``ids`` (CAN
1062
+ builds) also {"steppers":[...]} listing all of them.'''
1063
+ if not isinstance(r, dict):
1064
+ return False
1065
+ if isinstance(r.get("motor"), dict):
1066
+ return "steppers" in r["motor"]
1067
+ st = r.get("steppers")
1068
+ if not ids or not isinstance(st, list):
1069
+ return False
1070
+ return set(ids) <= {s.get("stepperid") for s in st if isinstance(s, dict)}
1071
+
1072
+ def get_position(self, axis=None, timeout=1, missing=0.0):
1043
1073
  '''
1044
- Positions in physical units as an array indexed by stepperid (0 where unknown).
1074
+ Positions in physical units as an array indexed by stepperid (``missing`` where unknown:
1075
+ no reply, or the axis not in it; pass np.nan to tell a lost reading from position 0).
1045
1076
  axis None: what a bare /motor_get lists (the board's own axes); a name, id or a list
1046
1077
  of them asks for exactly those axes, which CAN axes (e.g. ids 4-9 on a HAT) need.
1047
1078
  '''
@@ -1053,10 +1084,10 @@ class Motor(object):
1053
1084
  if axis is not None:
1054
1085
  axes = axis if isinstance(axis, (list, tuple)) else [axis]
1055
1086
  payload["motor"] = {"steppers": [{"stepperid": self.axis_id(a)} for a in axes]}
1056
- _position = np.zeros(self.nMotors)
1087
+ _position = np.full(self.nMotors, missing, dtype=float)
1057
1088
 
1058
- # this may be an asynchronous call.. #FIXME!
1059
- r = self._parent.post_json(path, payload, getReturn = True, nResponses=1, timeout=timeout)[0]
1089
+ r = self._post_motor_get(payload, timeout,
1090
+ ids=None if axis is None else [s["stepperid"] for s in payload["motor"]["steppers"]])
1060
1091
  # bare request: {"motor":{"steppers":[...]}}, filtered request (CAN builds): {"steppers":[...]}
1061
1092
  for istepper in r.get("motor", r).get("steppers", []) if isinstance(r, dict) else []:
1062
1093
  stepperID = istepper.get("stepperid", -1)
@@ -525,8 +525,9 @@ class Serial:
525
525
  message = json.dumps(message)
526
526
  return self.sendMessage(message, nResponses=0, timeout=timeout)
527
527
 
528
- def post_json(self, path, payload, getReturn=True, nResponses=1, timeout=100):
529
- """Make an HTTP POST request and return the JSON response"""
528
+ def post_json(self, path, payload, getReturn=True, nResponses=1, timeout=100, accept=None):
529
+ """Make an HTTP POST request and return the JSON response.
530
+ accept: see sendMessage."""
530
531
  if payload is None:
531
532
  payload = {}
532
533
  if "task" not in payload:
@@ -535,7 +536,7 @@ class Serial:
535
536
  # write message to the serial
536
537
  if not getReturn:
537
538
  nResponses = -1
538
- writeResult = self.sendMessage(command=payload, nResponses=nResponses, timeout=timeout)
539
+ writeResult = self.sendMessage(command=payload, nResponses=nResponses, timeout=timeout, accept=accept)
539
540
  return writeResult
540
541
 
541
542
  def writeSerial(self, payload):
@@ -560,12 +561,17 @@ class Serial:
560
561
  '''
561
562
  self.callBackList.append({"callbackfct":callback, "pattern":pattern})
562
563
 
563
- def sendMessage(self, command, nResponses=1, timeout = 20):
564
+ def sendMessage(self, command, nResponses=1, timeout = 20, accept=None):
564
565
  '''
565
566
  Sends a command to the device and optionally waits for a response.
566
567
  If nResponses is 0, then the command is sent but no response is expected.
567
568
  If nResponses is 1, then the command is sent and the response is returned.
568
569
  If nResponses is >1, then the command is sent and a list of responses is returned.
570
+ accept: optional predicate on one response. Responses are routed by their qid, and the
571
+ firmware also tags unsolicited frames with a qid (a motor's position push carries the
572
+ qid of that motor's last move; qids wrap after 254 commands), so the first frame with
573
+ our qid need not be the answer. With accept, wait until a response satisfies it and
574
+ return [that response] + the others.
569
575
  '''
570
576
  if type(command) == str:
571
577
  command = json.loads(command)
@@ -627,7 +633,16 @@ class Serial:
627
633
  self._logger.debug("Communication interrupted by timeout or reset for command: "+str(self.commands[identifier]))
628
634
  return "communication interrupted by timeout or reset: "+str(identifier) + " and code:"+str(self.commands[identifier])
629
635
  with self.lock:
630
- if identifier in self.responses:
636
+ if accept is not None and identifier in self.responses:
637
+ got = self.responses[identifier]
638
+ hit = next((r for r in got if accept(r)), None)
639
+ if hit is not None:
640
+ del self.responses[identifier]
641
+ others = [r for r in got if r is not hit]
642
+ if others and self.DEBUG:
643
+ self._logger.debug(f"qid {identifier}: skipped {len(others)} frame(s) that are not the answer: {others}")
644
+ return [hit] + others
645
+ elif identifier in self.responses:
631
646
  if len(self.responses[identifier])>=nResponses:
632
647
  returnMessage = self.responses[identifier]
633
648
  # remove the response from the list
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes