ephys-link 2.1.3__tar.gz → 2.2.0__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 (42) hide show
  1. {ephys_link-2.1.3 → ephys_link-2.2.0}/PKG-INFO +8 -9
  2. {ephys_link-2.1.3 → ephys_link-2.2.0}/mkdocs.yml +1 -0
  3. {ephys_link-2.1.3 → ephys_link-2.2.0}/pyproject.toml +14 -15
  4. ephys_link-2.2.0/src/ephys_link/__about__.py +1 -0
  5. {ephys_link-2.1.3 → ephys_link-2.2.0}/src/ephys_link/back_end/server.py +21 -47
  6. ephys_link-2.2.0/src/ephys_link/bindings/parallax_binding.py +274 -0
  7. {ephys_link-2.1.3 → ephys_link-2.2.0}/src/ephys_link/bindings/ump_binding.py +1 -1
  8. {ephys_link-2.1.3 → ephys_link-2.2.0}/src/ephys_link/front_end/cli.py +9 -17
  9. {ephys_link-2.1.3 → ephys_link-2.2.0}/src/ephys_link/front_end/gui.py +6 -27
  10. {ephys_link-2.1.3 → ephys_link-2.2.0}/src/ephys_link/utils/constants.py +0 -3
  11. {ephys_link-2.1.3 → ephys_link-2.2.0}/src/ephys_link/utils/startup.py +14 -8
  12. {ephys_link-2.1.3 → ephys_link-2.2.0}/tests/back_end/test_server.py +4 -144
  13. {ephys_link-2.1.3 → ephys_link-2.2.0}/tests/utils/test_startup.py +4 -3
  14. ephys_link-2.1.3/src/ephys_link/__about__.py +0 -1
  15. {ephys_link-2.1.3 → ephys_link-2.2.0}/.gitignore +0 -0
  16. {ephys_link-2.1.3 → ephys_link-2.2.0}/LICENSE +0 -0
  17. {ephys_link-2.1.3 → ephys_link-2.2.0}/README.md +0 -0
  18. {ephys_link-2.1.3 → ephys_link-2.2.0}/ephys_link.spec +0 -0
  19. {ephys_link-2.1.3 → ephys_link-2.2.0}/scripts/__init__.py +0 -0
  20. {ephys_link-2.1.3 → ephys_link-2.2.0}/scripts/gen_ref_pages.py +0 -0
  21. {ephys_link-2.1.3 → ephys_link-2.2.0}/scripts/jackhammer.py +0 -0
  22. {ephys_link-2.1.3 → ephys_link-2.2.0}/scripts/logger_test.py +0 -0
  23. {ephys_link-2.1.3 → ephys_link-2.2.0}/scripts/move_tester.py +0 -0
  24. {ephys_link-2.1.3 → ephys_link-2.2.0}/scripts/server_tester.py +0 -0
  25. {ephys_link-2.1.3 → ephys_link-2.2.0}/src/ephys_link/__init__.py +0 -0
  26. {ephys_link-2.1.3 → ephys_link-2.2.0}/src/ephys_link/__main__.py +0 -0
  27. {ephys_link-2.1.3 → ephys_link-2.2.0}/src/ephys_link/back_end/__init__.py +0 -0
  28. {ephys_link-2.1.3 → ephys_link-2.2.0}/src/ephys_link/back_end/platform_handler.py +0 -0
  29. {ephys_link-2.1.3 → ephys_link-2.2.0}/src/ephys_link/bindings/__init__.py +0 -0
  30. {ephys_link-2.1.3 → ephys_link-2.2.0}/src/ephys_link/bindings/fake_binding.py +0 -0
  31. {ephys_link-2.1.3 → ephys_link-2.2.0}/src/ephys_link/bindings/mpm_binding.py +0 -0
  32. {ephys_link-2.1.3 → ephys_link-2.2.0}/src/ephys_link/front_end/__init__.py +0 -0
  33. {ephys_link-2.1.3 → ephys_link-2.2.0}/src/ephys_link/front_end/console.py +0 -0
  34. {ephys_link-2.1.3 → ephys_link-2.2.0}/src/ephys_link/utils/__init__.py +0 -0
  35. {ephys_link-2.1.3 → ephys_link-2.2.0}/src/ephys_link/utils/base_binding.py +0 -0
  36. {ephys_link-2.1.3 → ephys_link-2.2.0}/src/ephys_link/utils/converters.py +0 -0
  37. {ephys_link-2.1.3 → ephys_link-2.2.0}/tests/__init__.py +0 -0
  38. {ephys_link-2.1.3 → ephys_link-2.2.0}/tests/back_end/__init__.py +0 -0
  39. {ephys_link-2.1.3 → ephys_link-2.2.0}/tests/back_end/test_platform_handler.py +0 -0
  40. {ephys_link-2.1.3 → ephys_link-2.2.0}/tests/conftest.py +0 -0
  41. {ephys_link-2.1.3 → ephys_link-2.2.0}/tests/utils/__init__.py +0 -0
  42. {ephys_link-2.1.3 → ephys_link-2.2.0}/tests/utils/test_converters.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ephys-link
3
- Version: 2.1.3
3
+ Version: 2.2.0
4
4
  Summary: A Python Socket.IO server that allows any Socket.IO-compliant application to communicate with manipulators used in electrophysiology experiments.
5
5
  Project-URL: Documentation, https://virtualbrainlab.org/ephys_link/installation_and_use.html
6
6
  Project-URL: Issues, https://github.com/VirtualBrainLab/ephys-link/issues
@@ -17,22 +17,21 @@ Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
17
17
  Classifier: Operating System :: Microsoft :: Windows
18
18
  Classifier: Programming Language :: Python
19
19
  Classifier: Programming Language :: Python :: 3
20
- Classifier: Programming Language :: Python :: 3.13
21
20
  Classifier: Programming Language :: Python :: Implementation :: CPython
22
21
  Classifier: Programming Language :: Python :: Implementation :: PyPy
23
22
  Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
24
- Requires-Python: >=3.13
25
- Requires-Dist: aiohttp==3.12.15
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: aiohttp==3.13.2
26
25
  Requires-Dist: colorama==0.4.6
27
26
  Requires-Dist: keyboard==0.13.5
28
27
  Requires-Dist: packaging==25.0
29
- Requires-Dist: platformdirs==4.4.0
28
+ Requires-Dist: platformdirs==4.5.1
30
29
  Requires-Dist: pyserial==3.5
31
- Requires-Dist: python-socketio[asyncio-client]==5.13.0
30
+ Requires-Dist: python-socketio==5.15.1
32
31
  Requires-Dist: requests==2.32.5
33
- Requires-Dist: rich==14.1.0
34
- Requires-Dist: sensapex==1.400.4
35
- Requires-Dist: vbl-aquarium==1.0.0
32
+ Requires-Dist: rich==14.2.0
33
+ Requires-Dist: sensapex==1.504.1
34
+ Requires-Dist: vbl-aquarium==1.2.0
36
35
  Description-Content-Type: text/markdown
37
36
 
38
37
  # Electrophysiology Manipulator Link
@@ -107,5 +107,6 @@ nav:
107
107
  - development/index.md
108
108
  - development/socketio_api.md
109
109
  - development/adding_a_manipulator.md
110
+ - development/jackhammer_mode.md
110
111
  - development/code_organization.md
111
112
  - Source Code Reference: reference/
@@ -7,14 +7,13 @@ name = "ephys-link"
7
7
  dynamic = ["version"]
8
8
  description = "A Python Socket.IO server that allows any Socket.IO-compliant application to communicate with manipulators used in electrophysiology experiments."
9
9
  readme = "README.md"
10
- requires-python = ">=3.13"
10
+ requires-python = ">=3.10"
11
11
  license = "GPL-3.0-only"
12
12
  keywords = ["socket-io", "manipulator", "electrophysiology", "ephys", "sensapex", "neuroscience", "neurotech", "virtualbrainlab", "new-scale"]
13
13
  authors = [{ name = "Kenneth Yang", email = "kjy5@uw.edu" }]
14
14
  maintainers = [{ name = "Kenneth Yang", email = "kjy5@uw.edu" }]
15
15
  classifiers = [
16
16
  "Programming Language :: Python",
17
- "Programming Language :: Python :: 3.13",
18
17
  "Programming Language :: Python :: Implementation :: CPython",
19
18
  "Programming Language :: Python :: Implementation :: PyPy",
20
19
  "Programming Language :: Python :: 3",
@@ -26,17 +25,17 @@ classifiers = [
26
25
  "Topic :: Scientific/Engineering :: Medical Science Apps.",
27
26
  ]
28
27
  dependencies = [
29
- "aiohttp==3.12.15",
28
+ "aiohttp==3.13.2",
30
29
  "colorama==0.4.6",
31
30
  "keyboard==0.13.5",
32
31
  "packaging==25.0",
33
- "platformdirs==4.4.0",
32
+ "platformdirs==4.5.1",
34
33
  "pyserial==3.5",
35
- "python-socketio[asyncio_client]==5.13.0",
34
+ "python-socketio==5.15.1",
36
35
  "requests==2.32.5",
37
- "sensapex==1.400.4",
38
- "rich==14.1.0",
39
- "vbl-aquarium==1.0.0"
36
+ "sensapex==1.504.1",
37
+ "rich==14.2.0",
38
+ "vbl-aquarium==1.2.0"
40
39
  ]
41
40
 
42
41
  [project.urls]
@@ -57,14 +56,14 @@ exclude = ["/.github", "/.idea", "/docs"]
57
56
 
58
57
  [tool.hatch.envs.default]
59
58
  installer = "uv"
60
- python = "3.13"
59
+ python = "3.14"
61
60
  dependencies = [
62
- "pyinstaller==6.15.0",
63
- "basedpyright==1.31.4",
61
+ "pyinstaller==6.16.0",
62
+ "basedpyright==1.32.1",
64
63
  "pytest==8.4.2",
65
- "pytest-cov==6.3.0",
66
- "pytest-mock==3.15.0",
67
- "pytest-asyncio==1.1.0"
64
+ "pytest-cov==7.0.0",
65
+ "pytest-mock==3.15.1",
66
+ "pytest-asyncio==1.2.0"
68
67
  ]
69
68
  [tool.hatch.envs.default.scripts]
70
69
  exe = "pyinstaller.exe ephys_link.spec -y -- -d && pyinstaller.exe ephys_link.spec -y"
@@ -76,7 +75,7 @@ cov = "pytest --cov=ephys_link --cov-report=html --cov-report=term-missing"
76
75
 
77
76
  [tool.hatch.envs.docs]
78
77
  installer = "uv"
79
- python = "3.13"
78
+ python = "3.14"
80
79
  skip-install = true
81
80
  dependencies = [
82
81
  "mkdocs-material==9.6.19",
@@ -0,0 +1 @@
1
+ __version__ = "2.2.0"
@@ -12,22 +12,20 @@ Usage:
12
12
  ```
13
13
  """
14
14
 
15
- from asyncio import get_event_loop, run
15
+ from asyncio import new_event_loop
16
16
  from collections.abc import Callable, Coroutine
17
17
  from json import JSONDecodeError, dumps, loads
18
18
  from typing import Any, TypeVar, final
19
- from uuid import uuid4
20
19
 
21
20
  from aiohttp.web import Application, run_app
22
21
  from pydantic import ValidationError
23
- from socketio import AsyncClient, AsyncServer # pyright: ignore [reportMissingTypeStubs]
22
+ from socketio import AsyncServer # pyright: ignore [reportMissingTypeStubs]
24
23
  from vbl_aquarium.models.ephys_link import (
25
24
  EphysLinkOptions,
26
25
  SetDepthRequest,
27
26
  SetInsideBrainRequest,
28
27
  SetPositionRequest,
29
28
  )
30
- from vbl_aquarium.models.proxy import PinpointIdResponse
31
29
  from vbl_aquarium.utils.vbl_base_model import VBLBaseModel
32
30
 
33
31
  from ephys_link.__about__ import __version__
@@ -36,8 +34,6 @@ from ephys_link.front_end.console import Console
36
34
  from ephys_link.utils.constants import (
37
35
  MALFORMED_REQUEST_ERROR,
38
36
  PORT,
39
- PROXY_CLIENT_NOT_INITIALIZED_ERROR,
40
- SERVER_NOT_INITIALIZED_ERROR,
41
37
  UNKNOWN_EVENT_ERROR,
42
38
  cannot_connect_as_client_is_already_connected_error,
43
39
  client_disconnected_without_being_connected_error,
@@ -64,60 +60,40 @@ class Server:
64
60
  self._platform_handler = platform_handler
65
61
  self._console = console
66
62
 
67
- # Initialize based on proxy usage.
68
- self._sio: AsyncServer | AsyncClient = AsyncClient() if self._options.use_proxy else AsyncServer()
69
- if not self._options.use_proxy:
70
- # Exit if _sio is not a Server.
71
- if not isinstance(self._sio, AsyncServer):
72
- self._console.critical_print(SERVER_NOT_INITIALIZED_ERROR)
73
- raise TypeError(SERVER_NOT_INITIALIZED_ERROR)
63
+ # Initialize server.
64
+ self._sio: AsyncServer = AsyncServer()
74
65
 
75
- self._app = Application()
76
- self._sio.attach(self._app) # pyright: ignore [reportUnknownMemberType]
66
+ self._app = Application()
67
+ self._sio.attach(self._app) # pyright: ignore [reportUnknownMemberType]
77
68
 
78
- # Bind connection events.
79
- _ = self._sio.on("connect", self.connect) # pyright: ignore [reportUnknownMemberType, reportUnknownVariableType]
80
- _ = self._sio.on("disconnect", self.disconnect) # pyright: ignore [reportUnknownMemberType, reportUnknownVariableType]
69
+ # Bind connection events.
70
+ _ = self._sio.on("connect", self.connect) # pyright: ignore [reportUnknownMemberType, reportUnknownVariableType]
71
+ _ = self._sio.on("disconnect", self.disconnect) # pyright: ignore [reportUnknownMemberType, reportUnknownVariableType]
81
72
 
82
73
  # Store connected client.
83
74
  self._client_sid: str = ""
84
75
 
85
- # Generate Pinpoint ID for proxy usage.
86
- self._pinpoint_id = str(uuid4())[:8]
87
-
88
76
  # Bind events.
89
77
  _ = self._sio.on("*", self.platform_event_handler) # pyright: ignore [reportUnknownMemberType, reportUnknownVariableType]
90
78
 
91
79
  def launch(self) -> None:
92
- """Launch the server.
93
-
94
- Based on the options, either connect to a proxy or launch the server locally.
95
- """
80
+ """Launch the server."""
96
81
 
97
82
  # List platform and available manipulators.
98
83
  self._console.info_print("PLATFORM", self._platform_handler.get_display_name())
99
- self._console.info_print(
100
- "MANIPULATORS",
101
- str(get_event_loop().run_until_complete(self._platform_handler.get_manipulators()).manipulators),
102
- )
103
84
 
104
- # Launch server
105
- if self._options.use_proxy:
106
- self._console.info_print("PINPOINT ID", self._pinpoint_id)
85
+ # Create a temporary event loop for getting manipulators
86
+ loop = new_event_loop()
87
+ try:
88
+ self._console.info_print(
89
+ "MANIPULATORS",
90
+ str(loop.run_until_complete(self._platform_handler.get_manipulators()).manipulators),
91
+ )
92
+ finally:
93
+ loop.close()
107
94
 
108
- async def connect_proxy() -> None:
109
- # Exit if _sio is not a proxy client.
110
- if not isinstance(self._sio, AsyncClient):
111
- self._console.critical_print(PROXY_CLIENT_NOT_INITIALIZED_ERROR)
112
- raise TypeError(PROXY_CLIENT_NOT_INITIALIZED_ERROR)
113
-
114
- # noinspection HttpUrlsUsage
115
- await self._sio.connect(f"http://{self._options.proxy_address}:{PORT}") # pyright: ignore [reportUnknownMemberType]
116
- await self._sio.wait()
117
-
118
- run(connect_proxy())
119
- else:
120
- run_app(self._app, port=PORT)
95
+ # Launch server
96
+ run_app(self._app, port=PORT)
121
97
 
122
98
  # Helper functions.
123
99
  def _malformed_request_response(self, request: str, data: tuple[tuple[Any], ...]) -> str: # pyright: ignore [reportExplicitAny]
@@ -244,8 +220,6 @@ class Server:
244
220
  # Server metadata.
245
221
  case "get_version":
246
222
  return __version__
247
- case "get_pinpoint_id":
248
- return PinpointIdResponse(pinpoint_id=self._pinpoint_id, is_requester=False).to_json_string()
249
223
  case "get_platform_info":
250
224
  return (await self._platform_handler.get_platform_info()).to_json_string()
251
225
 
@@ -0,0 +1,274 @@
1
+ """Bindings for Parallax for New Scale platform.
2
+
3
+ Usage: Instantiate ParallaxBinding to interact with the Parallax for New Scale Pathfinder platform.
4
+ """
5
+
6
+ from asyncio import get_running_loop, sleep
7
+ from json import dumps
8
+ from typing import Any, final, override
9
+
10
+ from requests import JSONDecodeError, get, put
11
+ from vbl_aquarium.models.unity import Vector3, Vector4
12
+
13
+ from ephys_link.utils.base_binding import BaseBinding
14
+ from ephys_link.utils.converters import scalar_mm_to_um, vector4_to_array
15
+
16
+
17
+ @final
18
+ class ParallaxBinding(BaseBinding):
19
+ """Bindings for Parallax for New Scale platform."""
20
+
21
+ # Server data update rate (30 FPS).
22
+ SERVER_DATA_UPDATE_RATE = 1 / 30
23
+
24
+ # Movement polling preferences.
25
+ UNCHANGED_COUNTER_LIMIT = 10
26
+
27
+ # Speed preferences (mm/s to use coarse mode).
28
+ COARSE_SPEED_THRESHOLD = 0.1
29
+ INSERTION_SPEED_LIMIT = 9_000
30
+
31
+ def __init__(self, port: int = 8081) -> None:
32
+ """Initialize connection to MPM HTTP server.
33
+
34
+ Args:
35
+ port: Port number for MPM HTTP server.
36
+ """
37
+ self._url = f"http://localhost:{port}"
38
+ self._movement_stopped = False
39
+
40
+ # Data cache.
41
+ self.cache: dict[str, Any] = {} # pyright: ignore [reportExplicitAny]
42
+ self.cache_time = 0
43
+
44
+ @staticmethod
45
+ @override
46
+ def get_display_name() -> str:
47
+ return "Parallax for New Scale"
48
+
49
+ @staticmethod
50
+ @override
51
+ def get_cli_name() -> str:
52
+ return "parallax"
53
+
54
+ @override
55
+ async def get_manipulators(self) -> list[str]:
56
+ data = await self._query_data()
57
+ return list(data.keys())
58
+
59
+ @override
60
+ async def get_axes_count(self) -> int:
61
+ return 3
62
+
63
+ @override
64
+ def get_dimensions(self) -> Vector4:
65
+ return Vector4(x=15, y=15, z=15, w=15)
66
+
67
+ @override
68
+ async def get_position(self, manipulator_id: str) -> Vector4:
69
+ manipulator_data: dict[str, Any] = await self._manipulator_data(manipulator_id) # pyright: ignore [reportExplicitAny]
70
+ global_z = float(manipulator_data.get("global_Z", 0.0) or 0.0)
71
+
72
+ await sleep(self.SERVER_DATA_UPDATE_RATE) # Wait for the stage to stabilize.
73
+
74
+ global_x = float(manipulator_data.get("global_X", 0.0) or 0.0)
75
+ global_y = float(manipulator_data.get("global_Y", 0.0) or 0.0)
76
+
77
+ return Vector4(x=global_x, y=global_y, z=global_z, w=global_z)
78
+
79
+ @override
80
+ async def get_angles(self, manipulator_id: str) -> Vector3:
81
+ manipulator_data: dict[str, Any] = await self._manipulator_data(manipulator_id) # pyright: ignore [reportExplicitAny]
82
+
83
+ yaw = int(manipulator_data.get("yaw", 0) or 0)
84
+ pitch = int(manipulator_data.get("pitch", 90) or 90)
85
+ roll = int(manipulator_data.get("roll", 0) or 0)
86
+
87
+ return Vector3(x=yaw, y=pitch, z=roll)
88
+
89
+ @override
90
+ async def get_shank_count(self, manipulator_id: str) -> int:
91
+ manipulator_data: dict[str, Any] = await self._manipulator_data(manipulator_id) # pyright: ignore [reportExplicitAny]
92
+ return int(manipulator_data.get("shank_cnt", 1) or 1)
93
+
94
+ @staticmethod
95
+ @override
96
+ def get_movement_tolerance() -> float:
97
+ return 0.01
98
+
99
+ @override
100
+ async def set_position(self, manipulator_id: str, position: Vector4, speed: float) -> Vector4:
101
+ # Keep track of the previous position to check if the manipulator stopped advancing.
102
+ current_position = await self.get_position(manipulator_id)
103
+ previous_position = current_position
104
+ unchanged_counter = 0
105
+
106
+ # Set step mode based on speed.
107
+ await self._put_request(
108
+ {
109
+ "move_type": "stepMode",
110
+ "stage_sn": manipulator_id,
111
+ "step_mode": 0 if speed > self.COARSE_SPEED_THRESHOLD else 1,
112
+ }
113
+ )
114
+
115
+ # Send move request.
116
+ await self._put_request(
117
+ {
118
+ "move_type": "moveXYZ",
119
+ "world": "global", # Use global coordinates
120
+ "stage_sn": manipulator_id,
121
+ "Absolute": 1,
122
+ "Stereotactic": 0,
123
+ "AxisMask": 7,
124
+ "x": position.x,
125
+ "y": position.y,
126
+ "z": position.z,
127
+ }
128
+ )
129
+ # Wait for the manipulator to reach the target position or be stopped or stuck.
130
+ while (
131
+ not self._movement_stopped
132
+ and not self._is_vector_close(current_position, position)
133
+ and unchanged_counter < self.UNCHANGED_COUNTER_LIMIT
134
+ ):
135
+ # Wait for a short time before checking again.
136
+ await sleep(self.SERVER_DATA_UPDATE_RATE)
137
+
138
+ # Update current position.
139
+ current_position = await self.get_position(manipulator_id)
140
+
141
+ # Check if manipulator is not moving.
142
+ if self._is_vector_close(previous_position, current_position):
143
+ # Position did not change.
144
+ unchanged_counter += 1
145
+ else:
146
+ # Position changed.
147
+ unchanged_counter = 0
148
+ previous_position = current_position
149
+
150
+ # Reset movement stopped flag.
151
+ self._movement_stopped = False
152
+
153
+ # Return the final position.
154
+ return await self.get_position(manipulator_id)
155
+
156
+ @override
157
+ async def set_depth(self, manipulator_id: str, depth: float, speed: float) -> float:
158
+ # Keep track of the previous depth to check if the manipulator stopped advancing unexpectedly.
159
+ current_depth = (await self.get_position(manipulator_id)).w
160
+ previous_depth = current_depth
161
+ unchanged_counter = 0
162
+
163
+ # Send move request.
164
+ # Convert mm/s to um/min and cap speed at the limit.
165
+ await self._put_request(
166
+ {
167
+ "move_type": "insertion",
168
+ "stage_sn": manipulator_id,
169
+ "world": "global", # distance in global space
170
+ "distance": scalar_mm_to_um(current_depth - depth),
171
+ "rate": min(scalar_mm_to_um(speed) * 60, self.INSERTION_SPEED_LIMIT),
172
+ }
173
+ )
174
+
175
+ # Wait for the manipulator to reach the target depth or be stopped or get stuck.
176
+ while (
177
+ not self._movement_stopped
178
+ and not abs(current_depth - depth) <= self.get_movement_tolerance()
179
+ and unchanged_counter < self.UNCHANGED_COUNTER_LIMIT
180
+ ):
181
+ # Wait for a short time before checking again.
182
+ await sleep(self.SERVER_DATA_UPDATE_RATE)
183
+
184
+ # Get the current depth.
185
+ current_depth = (await self.get_position(manipulator_id)).w
186
+
187
+ # Check if manipulator is not moving.
188
+ if abs(previous_depth - current_depth) <= self.get_movement_tolerance():
189
+ # Depth did not change.
190
+ unchanged_counter += 1
191
+ else:
192
+ # Depth changed.
193
+ unchanged_counter = 0
194
+ previous_depth = current_depth
195
+
196
+ # Reset movement stopped flag.
197
+ self._movement_stopped = False
198
+
199
+ # Return the final depth.
200
+ return float((await self.get_position(manipulator_id)).w)
201
+
202
+ @override
203
+ async def stop(self, manipulator_id: str) -> None:
204
+ request: dict[str, str | int | float] = {
205
+ "PutId": "stop",
206
+ "Probe": manipulator_id,
207
+ }
208
+ await self._put_request(request)
209
+ self._movement_stopped = True
210
+
211
+ @override
212
+ def platform_space_to_unified_space(self, platform_space: Vector4) -> Vector4:
213
+ # unified <- platform
214
+ # +x <- +x
215
+ # +y <- +z
216
+ # +z <- +y
217
+ # +w <- +w
218
+
219
+ return Vector4(
220
+ x=platform_space.x,
221
+ y=platform_space.z,
222
+ z=platform_space.y,
223
+ w=self.get_dimensions().w - platform_space.w,
224
+ )
225
+
226
+ @override
227
+ def unified_space_to_platform_space(self, unified_space: Vector4) -> Vector4:
228
+ # platform <- unified
229
+ # +x <- +x
230
+ # +y <- +z
231
+ # +z <- +y
232
+ # +w <- -w
233
+
234
+ return Vector4(
235
+ x=unified_space.x,
236
+ y=unified_space.z,
237
+ z=unified_space.y,
238
+ w=self.get_dimensions().w - unified_space.w,
239
+ )
240
+
241
+ # Helper functions.
242
+ async def _query_data(self) -> dict[str, Any]: # pyright: ignore [reportExplicitAny]
243
+ try:
244
+ # Update cache if it's expired.
245
+ if get_running_loop().time() - self.cache_time > self.SERVER_DATA_UPDATE_RATE:
246
+ # noinspection PyTypeChecker
247
+ self.cache = (await get_running_loop().run_in_executor(None, get, self._url)).json()
248
+ self.cache_time = get_running_loop().time()
249
+ except ConnectionError as connectionError:
250
+ error_message = f"Unable to connect to MPM HTTP server: {connectionError}"
251
+ raise RuntimeError(error_message) from connectionError
252
+ except JSONDecodeError as jsonDecodeError:
253
+ error_message = f"Unable to decode JSON response from MPM HTTP server: {jsonDecodeError}"
254
+ raise ValueError(error_message) from jsonDecodeError
255
+ else:
256
+ # Return cached data.
257
+ return self.cache
258
+
259
+ async def _manipulator_data(self, manipulator_id: str) -> dict[str, Any]: # pyright: ignore [reportExplicitAny]
260
+ """Retrieve data for a specific manipulator (probe) using its serial number."""
261
+ data = await self._query_data()
262
+
263
+ if manipulator_id in data:
264
+ return data[manipulator_id] # pyright: ignore [reportAny]
265
+
266
+ # If we get here, that means the manipulator doesn't exist.
267
+ error_message = f"Manipulator {manipulator_id} not found."
268
+ raise ValueError(error_message)
269
+
270
+ async def _put_request(self, request: dict[str, Any]) -> None: # pyright: ignore [reportExplicitAny]
271
+ _ = await get_running_loop().run_in_executor(None, put, self._url, dumps(request))
272
+
273
+ def _is_vector_close(self, target: Vector4, current: Vector4) -> bool:
274
+ return all(abs(axis) <= self.get_movement_tolerance() for axis in vector4_to_array(target - current)[:3])
@@ -142,7 +142,7 @@ class UmpBinding(BaseBinding):
142
142
 
143
143
  @override
144
144
  async def stop(self, manipulator_id: str) -> None:
145
- self._get_device(manipulator_id).stop()
145
+ self._get_device(manipulator_id).stop() # pyright: ignore [reportUnknownMemberType]
146
146
 
147
147
  @override
148
148
  def platform_space_to_unified_space(self, platform_space: Vector4) -> Vector4:
@@ -47,7 +47,7 @@ class CLI:
47
47
  type=str,
48
48
  dest="type",
49
49
  default="ump",
50
- help='Manipulator type (i.e. "ump", "pathfinder-mpm", "fake"). Default: "ump".',
50
+ help='Manipulator type ("ump", "pathfinder-mpm", "parallax", "fake"). Default: "ump".',
51
51
  )
52
52
  _ = self._parser.add_argument(
53
53
  "-d",
@@ -56,27 +56,19 @@ class CLI:
56
56
  action="store_true",
57
57
  help="Enable debug mode.",
58
58
  )
59
- _ = self._parser.add_argument(
60
- "-p",
61
- "--use-proxy",
62
- dest="use_proxy",
63
- action="store_true",
64
- help="Enable proxy mode.",
65
- )
66
- _ = self._parser.add_argument(
67
- "-a",
68
- "--proxy-address",
69
- type=str,
70
- default="proxy2.virtualbrainlab.org",
71
- dest="proxy_address",
72
- help="Proxy IP address.",
73
- )
74
59
  _ = self._parser.add_argument(
75
60
  "--mpm-port",
76
61
  type=int,
77
62
  default=8080,
78
63
  dest="mpm_port",
79
- help="Port New Scale Pathfinder MPM's server is on. Default: 8080.",
64
+ help="HTTP port New Scale Pathfinder MPM's server is on. Default: 8080.",
65
+ )
66
+ _ = self._parser.add_argument(
67
+ "--parallax-port",
68
+ type=int,
69
+ default=8081,
70
+ dest="parallax_port",
71
+ help="HTTP port Parallax's server is on. Default: 8081.",
80
72
  )
81
73
  _ = self._parser.add_argument(
82
74
  "-s",
@@ -12,7 +12,7 @@ from json import load
12
12
  from os import makedirs
13
13
  from os.path import exists, join
14
14
  from socket import gethostbyname, gethostname
15
- from sys import exit
15
+ from sys import exit as sys_exit
16
16
  from tkinter import CENTER, RIGHT, BooleanVar, E, IntVar, StringVar, Tk, ttk
17
17
  from typing import final
18
18
 
@@ -52,8 +52,6 @@ class GUI:
52
52
  self._ignore_updates = BooleanVar(value=options.ignore_updates)
53
53
  self._type = StringVar(value=options.type)
54
54
  self._debug = BooleanVar(value=options.debug)
55
- self._use_proxy = BooleanVar(value=options.use_proxy)
56
- self._proxy_address = StringVar(value=options.proxy_address)
57
55
  self._mpm_port = IntVar(value=options.mpm_port)
58
56
  self._serial = StringVar(value=options.serial)
59
57
 
@@ -73,15 +71,13 @@ class GUI:
73
71
 
74
72
  # Exit if the user did not submit options.
75
73
  if not self._submit:
76
- exit(1)
74
+ sys_exit(1)
77
75
 
78
76
  # Extract options from GUI.
79
77
  options = EphysLinkOptions(
80
78
  ignore_updates=self._ignore_updates.get(),
81
79
  type=self._type.get(),
82
80
  debug=self._debug.get(),
83
- use_proxy=self._use_proxy.get(),
84
- proxy_address=self._proxy_address.get(),
85
81
  mpm_port=self._mpm_port.get(),
86
82
  serial=self._serial.get(),
87
83
  )
@@ -115,40 +111,23 @@ class GUI:
115
111
  ttk.Label(server_serving_settings, text="Local IP:", anchor=E, justify=RIGHT).grid(column=0, row=0, sticky="we")
116
112
  ttk.Label(server_serving_settings, text=gethostbyname(gethostname())).grid(column=1, row=0, sticky="we")
117
113
 
118
- # Proxy.
119
- ttk.Label(server_serving_settings, text="Use Proxy:", anchor=E, justify=RIGHT).grid(
120
- column=0, row=1, sticky="we"
121
- )
122
- ttk.Checkbutton(
123
- server_serving_settings,
124
- variable=self._use_proxy,
125
- ).grid(column=1, row=1, sticky="we")
126
-
127
- # Proxy address.
128
- ttk.Label(server_serving_settings, text="Proxy Address:", anchor=E, justify=RIGHT).grid(
129
- column=0, row=2, sticky="we"
130
- )
131
- ttk.Entry(server_serving_settings, textvariable=self._proxy_address, justify=CENTER).grid(
132
- column=1, row=2, sticky="we"
133
- )
134
-
135
114
  # Ignore updates.
136
115
  ttk.Label(server_serving_settings, text="Ignore Updates:", anchor=E, justify=RIGHT).grid(
137
- column=0, row=4, sticky="we"
116
+ column=0, row=1, sticky="we"
138
117
  )
139
118
  ttk.Checkbutton(
140
119
  server_serving_settings,
141
120
  variable=self._ignore_updates,
142
- ).grid(column=1, row=4, sticky="we")
121
+ ).grid(column=1, row=1, sticky="we")
143
122
 
144
123
  # Debug mode.
145
124
  ttk.Label(server_serving_settings, text="Debug mode:", anchor=E, justify=RIGHT).grid(
146
- column=0, row=5, sticky="we"
125
+ column=0, row=2, sticky="we"
147
126
  )
148
127
  ttk.Checkbutton(
149
128
  server_serving_settings,
150
129
  variable=self._debug,
151
- ).grid(column=1, row=5, sticky="we")
130
+ ).grid(column=1, row=2, sticky="we")
152
131
 
153
132
  # ---
154
133
 
@@ -58,9 +58,6 @@ def did_not_reach_target_depth_error(request: SetDepthRequest, final_unified_dep
58
58
 
59
59
  EMERGENCY_STOP_MESSAGE = "Emergency Stopping All Manipulators..."
60
60
 
61
- SERVER_NOT_INITIALIZED_ERROR = "Server not initialized."
62
- PROXY_CLIENT_NOT_INITIALIZED_ERROR = "Proxy client not initialized."
63
-
64
61
 
65
62
  def cannot_connect_as_client_is_already_connected_error(new_client_sid: str, current_client_sid: str) -> str:
66
63
  """Generate an error message for when the client is already connected.
@@ -5,11 +5,14 @@ from inspect import getmembers, isclass
5
5
  from pkgutil import iter_modules
6
6
 
7
7
  from packaging.version import parse
8
- from requests import ConnectionError, ConnectTimeout, get
8
+ from requests import ConnectionError as RequestsConnectionError
9
+ from requests import ConnectTimeout as RequestsConnectTimeout
10
+ from requests import get
9
11
  from vbl_aquarium.models.ephys_link import EphysLinkOptions
10
12
 
11
13
  from ephys_link.__about__ import __version__
12
14
  from ephys_link.bindings.mpm_binding import MPMBinding
15
+ from ephys_link.bindings.parallax_binding import ParallaxBinding
13
16
  from ephys_link.front_end.console import Console
14
17
  from ephys_link.utils.base_binding import BaseBinding
15
18
  from ephys_link.utils.constants import (
@@ -44,7 +47,7 @@ def check_for_updates(console: Console) -> None:
44
47
  if parse(latest_version) > parse(__version__):
45
48
  console.critical_print(f"Update available: {latest_version} (current: {__version__})")
46
49
  console.critical_print("Download at: https://github.com/VirtualBrainLab/ephys-link/releases/latest")
47
- except (ConnectionError, ConnectTimeout):
50
+ except (RequestsConnectionError, RequestsConnectTimeout):
48
51
  console.error_print("UPDATE", UNABLE_TO_CHECK_FOR_UPDATES_ERROR)
49
52
 
50
53
 
@@ -89,12 +92,15 @@ def get_binding_instance(options: EphysLinkOptions, console: Console) -> BaseBin
89
92
  selected_type = "ump"
90
93
 
91
94
  if binding_cli_name == selected_type:
92
- # Pass in HTTP port for Pathfinder MPM.
93
- if binding_cli_name == "pathfinder-mpm":
94
- return MPMBinding(options.mpm_port)
95
-
96
- # Otherwise just return the binding.
97
- return binding_type()
95
+ # Pass in HTTP port for Pathfinder MPM and Parallax.
96
+ match binding_cli_name:
97
+ case "pathfinder-mpm":
98
+ return MPMBinding(options.mpm_port)
99
+ case "parallax":
100
+ return ParallaxBinding(options.parallax_port)
101
+ case _:
102
+ # Otherwise just return the binding.
103
+ return binding_type()
98
104
 
99
105
  # Raise an error if the platform type is not recognized.
100
106
  error_message = unrecognized_platform_type_error(selected_type)
@@ -1,10 +1,7 @@
1
- import asyncio
2
- from collections.abc import Awaitable
3
1
  from json import dumps, loads
4
2
 
5
3
  import pytest
6
4
  from pytest_mock import MockerFixture
7
- from socketio import AsyncClient, AsyncServer # pyright: ignore[reportMissingTypeStubs]
8
5
  from vbl_aquarium.models.ephys_link import (
9
6
  AngularResponse,
10
7
  BooleanStateResponse,
@@ -18,7 +15,6 @@ from vbl_aquarium.models.ephys_link import (
18
15
  SetPositionRequest,
19
16
  ShankCountResponse,
20
17
  )
21
- from vbl_aquarium.models.proxy import PinpointIdResponse
22
18
 
23
19
  import ephys_link.back_end.server
24
20
  from ephys_link.__about__ import __version__
@@ -27,8 +23,6 @@ from ephys_link.back_end.server import Server
27
23
  from ephys_link.front_end.console import Console
28
24
  from ephys_link.utils.constants import (
29
25
  MALFORMED_REQUEST_ERROR,
30
- PROXY_CLIENT_NOT_INITIALIZED_ERROR,
31
- SERVER_NOT_INITIALIZED_ERROR,
32
26
  UNKNOWN_EVENT_ERROR,
33
27
  cannot_connect_as_client_is_already_connected_error,
34
28
  client_disconnected_without_being_connected_error,
@@ -50,27 +44,7 @@ class TestServer:
50
44
  @pytest.fixture
51
45
  def server(self, platform_handler: PlatformHandler, console: Console) -> Server:
52
46
  """Fixture for server."""
53
- return Server(EphysLinkOptions(use_proxy=False), platform_handler, console)
54
-
55
- @pytest.fixture
56
- def proxy_client(self, platform_handler: PlatformHandler, console: Console) -> Server:
57
- """Fixture for server as proxy client."""
58
- return Server(EphysLinkOptions(use_proxy=True), platform_handler, console)
59
-
60
- def test_failed_server_init(
61
- self, platform_handler: PlatformHandler, console: Console, mocker: MockerFixture
62
- ) -> None:
63
- """Server should raise error if sio is not an AsyncServer."""
64
- # Mock out the AsyncServer init.
65
- patched_async_server = mocker.patch.object(AsyncServer, "__new__")
66
-
67
- # Act
68
- with pytest.raises(TypeError) as init_error:
69
- _ = Server(EphysLinkOptions(use_proxy=False), platform_handler, console)
70
-
71
- # Assert
72
- patched_async_server.assert_called_once()
73
- assert init_error.value.args[0] == SERVER_NOT_INITIALIZED_ERROR
47
+ return Server(EphysLinkOptions(), platform_handler, console)
74
48
 
75
49
  def test_launch_server(
76
50
  self, server: Server, platform_handler: PlatformHandler, console: Console, mocker: MockerFixture
@@ -88,8 +62,8 @@ class TestServer:
88
62
  patched_run_until_complete = mocker.patch.object(
89
63
  asyncio_loop, "run_until_complete", return_value=GetManipulatorsResponse(manipulators=DUMMY_STRING_LIST)
90
64
  )
91
- patched_get_event_loop = mocker.patch.object(
92
- ephys_link.back_end.server, "get_event_loop", return_value=asyncio_loop
65
+ patched_new_event_loop = mocker.patch.object(
66
+ ephys_link.back_end.server, "new_event_loop", return_value=asyncio_loop
93
67
  )
94
68
 
95
69
  # Mock out run_app.
@@ -102,103 +76,11 @@ class TestServer:
102
76
  patched_get_display_name.assert_called_once()
103
77
  patched_get_manipulators.assert_called_once()
104
78
  patched_run_until_complete.assert_called_once()
105
- patched_get_event_loop.assert_called_once()
79
+ patched_new_event_loop.assert_called_once()
106
80
  spied_info_print.assert_any_call("PLATFORM", platform_handler.get_display_name())
107
81
  spied_info_print.assert_any_call("MANIPULATORS", str(DUMMY_STRING_LIST))
108
82
  mocked_run_app.assert_called_once()
109
83
 
110
- def test_launch_proxy_client(
111
- self, proxy_client: Server, platform_handler: PlatformHandler, console: Console, mocker: MockerFixture
112
- ) -> None:
113
- """Proxy client should print info then start."""
114
- # Add mocks and spies.
115
- # noinspection DuplicatedCode
116
- spied_info_print = mocker.spy(console, "info_print")
117
-
118
- patched_get_display_name = mocker.patch.object(platform_handler, "get_display_name", return_value=DUMMY_STRING)
119
-
120
- # Mock out get manipulators.
121
- patched_get_manipulators = mocker.patch.object(platform_handler, "get_manipulators", new=mocker.Mock())
122
- asyncio_loop = mocker.Mock()
123
- patched_run_until_complete = mocker.patch.object(
124
- asyncio_loop, "run_until_complete", return_value=GetManipulatorsResponse(manipulators=DUMMY_STRING_LIST)
125
- )
126
- patched_get_event_loop = mocker.patch.object(
127
- ephys_link.back_end.server, "get_event_loop", return_value=asyncio_loop
128
- )
129
-
130
- # Mock out run.
131
- def run_coroutine(coroutine: Awaitable[None]) -> None:
132
- """Run the coroutine."""
133
- asyncio.new_event_loop().run_until_complete(coroutine)
134
-
135
- _ = mocker.patch.object(ephys_link.back_end.server, "run", new=run_coroutine)
136
- patched_connect = mocker.patch.object(AsyncClient, "connect", new_callable=mocker.AsyncMock)
137
- patched_wait = mocker.patch.object(AsyncClient, "wait", new_callable=mocker.AsyncMock)
138
-
139
- # Act.
140
- proxy_client.launch()
141
-
142
- # Assert.
143
- patched_get_display_name.assert_called_once()
144
- patched_get_manipulators.assert_called_once()
145
- patched_run_until_complete.assert_called_once()
146
- patched_get_event_loop.assert_called_once()
147
- spied_info_print.assert_any_call("PLATFORM", platform_handler.get_display_name())
148
- spied_info_print.assert_any_call("MANIPULATORS", str(DUMMY_STRING_LIST))
149
- spied_info_print.assert_any_call("PINPOINT ID", mocker.ANY) # pyright: ignore[reportAny]
150
- patched_connect.assert_awaited_once() # pyright: ignore[reportUnusedCallResult]
151
- patched_wait.assert_awaited_once() # pyright: ignore[reportUnusedCallResult]
152
-
153
- def test_launch_proxy_client_failed_init(
154
- self, platform_handler: PlatformHandler, console: Console, mocker: MockerFixture
155
- ) -> None:
156
- """Proxy client should print info then start."""
157
- # Add mocks and spies.
158
- # noinspection DuplicatedCode
159
- spied_info_print = mocker.spy(console, "info_print")
160
-
161
- patched_get_display_name = mocker.patch.object(platform_handler, "get_display_name", return_value=DUMMY_STRING)
162
-
163
- # Mock out get manipulators.
164
- patched_get_manipulators = mocker.patch.object(platform_handler, "get_manipulators", new=mocker.Mock())
165
- asyncio_loop = mocker.Mock()
166
- patched_run_until_complete = mocker.patch.object(
167
- asyncio_loop, "run_until_complete", return_value=GetManipulatorsResponse(manipulators=DUMMY_STRING_LIST)
168
- )
169
- patched_get_event_loop = mocker.patch.object(
170
- ephys_link.back_end.server, "get_event_loop", return_value=asyncio_loop
171
- )
172
-
173
- # Mock out run.
174
- def run_coroutine(coroutine: Awaitable[None]) -> None:
175
- """Run the coroutine."""
176
- asyncio.new_event_loop().run_until_complete(coroutine)
177
-
178
- _ = mocker.patch.object(ephys_link.back_end.server, "run", new=run_coroutine)
179
- patched_connect = mocker.patch.object(AsyncClient, "connect", new_callable=mocker.AsyncMock)
180
- patched_wait = mocker.patch.object(AsyncClient, "wait", new_callable=mocker.AsyncMock)
181
-
182
- # Mock out the AsyncServer init.
183
- patched_async_server = mocker.patch.object(AsyncClient, "__new__")
184
-
185
- # Act
186
- with pytest.raises(TypeError) as init_error:
187
- Server(EphysLinkOptions(use_proxy=True), platform_handler, console).launch()
188
-
189
- # Assert.
190
- patched_async_server.assert_called_once()
191
- patched_get_display_name.assert_called_once()
192
- patched_get_manipulators.assert_called_once()
193
- patched_run_until_complete.assert_called_once()
194
- patched_get_event_loop.assert_called_once()
195
- spied_info_print.assert_any_call("PLATFORM", platform_handler.get_display_name())
196
- spied_info_print.assert_any_call("MANIPULATORS", str(DUMMY_STRING_LIST))
197
- spied_info_print.assert_any_call("PINPOINT ID", mocker.ANY) # pyright: ignore[reportAny]
198
- assert init_error.value.args[0] == PROXY_CLIENT_NOT_INITIALIZED_ERROR
199
- patched_connect.assert_not_awaited() # pyright: ignore[reportUnusedCallResult]
200
- patched_wait.assert_not_awaited() # pyright: ignore[reportUnusedCallResult]
201
-
202
84
  @pytest.mark.asyncio
203
85
  async def test_connect_success(self, server: Server, console: Console, mocker: MockerFixture) -> None:
204
86
  """Server should allow connection if there is no existing connection."""
@@ -283,28 +165,6 @@ class TestServer:
283
165
  spied_info_print.assert_called_once_with("EVENT", event_name)
284
166
  assert result == __version__
285
167
 
286
- @pytest.mark.asyncio
287
- async def test_platform_event_handler_get_pinpoint_id(
288
- self, server: Server, console: Console, mocker: MockerFixture
289
- ) -> None:
290
- """Server should return pinpoint ID."""
291
- # Spy console.
292
- spied_info_print = mocker.spy(console, "debug_print")
293
-
294
- # Mock Pinpoint ID.
295
- dummy_id = DUMMY_STRING[:8]
296
- _ = mocker.patch.object(server, "_pinpoint_id", new=dummy_id)
297
-
298
- # Act.
299
- event_name = "get_pinpoint_id"
300
- result = await server.platform_event_handler(event_name, DUMMY_STRING, None)
301
- parsed_result = PinpointIdResponse(**loads(result)) # pyright: ignore[reportAny]
302
-
303
- # Assert.
304
- spied_info_print.assert_called_once_with("EVENT", event_name)
305
- assert parsed_result.pinpoint_id == dummy_id
306
- assert not parsed_result.is_requester
307
-
308
168
  @pytest.mark.asyncio
309
169
  async def test_platform_event_handler_get_platform_info(
310
170
  self, platform_handler: PlatformHandler, server: Server, console: Console, mocker: MockerFixture
@@ -3,7 +3,8 @@ from io import StringIO
3
3
 
4
4
  import pytest
5
5
  from pytest_mock import MockerFixture
6
- from requests import ConnectionError, ConnectTimeout
6
+ from requests import ConnectionError as RequestsConnectionError
7
+ from requests import ConnectTimeout as RequestsConnectTimeout
7
8
  from vbl_aquarium.models.ephys_link import EphysLinkOptions
8
9
 
9
10
  from ephys_link.__about__ import __version__
@@ -76,9 +77,9 @@ class TestStartup:
76
77
  # Assert: critical_print should be called since an update is available.
77
78
  spied_critical_print.assert_called()
78
79
 
79
- @pytest.mark.parametrize("exception", [ConnectionError, ConnectTimeout])
80
+ @pytest.mark.parametrize("exception", [RequestsConnectionError, RequestsConnectTimeout])
80
81
  def test_check_for_updates_connection_errors(
81
- self, exception: ConnectionError | ConnectTimeout, console: Console, mocker: MockerFixture
82
+ self, exception: RequestsConnectionError | RequestsConnectTimeout, console: Console, mocker: MockerFixture
82
83
  ) -> None:
83
84
  """Test the check_for_updates function with connection-related errors."""
84
85
  # Add mocks and spies.
@@ -1 +0,0 @@
1
- __version__ = "2.1.3"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes