rocket-welder-sdk 1.1.27__py3-none-any.whl → 1.1.28__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.
- rocket_welder_sdk/__init__.py +7 -1
- rocket_welder_sdk/connection_string.py +51 -0
- rocket_welder_sdk/controllers.py +32 -24
- rocket_welder_sdk/opencv_controller.py +278 -0
- rocket_welder_sdk/periodic_timer.py +303 -0
- rocket_welder_sdk/rocket_welder_client.py +246 -9
- {rocket_welder_sdk-1.1.27.dist-info → rocket_welder_sdk-1.1.28.dist-info}/METADATA +130 -1
- {rocket_welder_sdk-1.1.27.dist-info → rocket_welder_sdk-1.1.28.dist-info}/RECORD +10 -8
- {rocket_welder_sdk-1.1.27.dist-info → rocket_welder_sdk-1.1.28.dist-info}/WHEEL +0 -0
- {rocket_welder_sdk-1.1.27.dist-info → rocket_welder_sdk-1.1.28.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PeriodicTimer implementation for Python, similar to .NET's System.Threading.PeriodicTimer.
|
|
3
|
+
|
|
4
|
+
Provides an async periodic timer that enables waiting asynchronously for timer ticks.
|
|
5
|
+
This is particularly useful for rendering and periodic frame updates.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import contextlib
|
|
12
|
+
import time
|
|
13
|
+
from datetime import timedelta
|
|
14
|
+
from typing import TYPE_CHECKING, Any, List, Optional, Union
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from types import TracebackType
|
|
18
|
+
else:
|
|
19
|
+
TracebackType = type
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PeriodicTimer:
|
|
23
|
+
"""
|
|
24
|
+
A periodic timer that enables waiting asynchronously for timer ticks.
|
|
25
|
+
|
|
26
|
+
Similar to .NET's PeriodicTimer, this class provides:
|
|
27
|
+
- Async-first design with wait_for_next_tick_async()
|
|
28
|
+
- Single consumer model (only one wait call at a time)
|
|
29
|
+
- Proper cancellation support
|
|
30
|
+
- No callback-based design (work is done in the calling scope)
|
|
31
|
+
|
|
32
|
+
Example:
|
|
33
|
+
async def render_loop():
|
|
34
|
+
timer = PeriodicTimer(timedelta(seconds=1/60)) # 60 FPS
|
|
35
|
+
try:
|
|
36
|
+
while await timer.wait_for_next_tick_async():
|
|
37
|
+
# Render frame
|
|
38
|
+
await render_frame()
|
|
39
|
+
finally:
|
|
40
|
+
timer.dispose()
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, period: Union[timedelta, float]):
|
|
44
|
+
"""
|
|
45
|
+
Initialize a new PeriodicTimer.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
period: Time interval between ticks. Can be timedelta or float (seconds).
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
ValueError: If period is negative or zero.
|
|
52
|
+
"""
|
|
53
|
+
if isinstance(period, timedelta):
|
|
54
|
+
self._period_seconds = period.total_seconds()
|
|
55
|
+
else:
|
|
56
|
+
self._period_seconds = float(period)
|
|
57
|
+
|
|
58
|
+
if self._period_seconds <= 0:
|
|
59
|
+
raise ValueError("Period must be positive")
|
|
60
|
+
|
|
61
|
+
self._start_time = time.monotonic()
|
|
62
|
+
self._tick_count = 0
|
|
63
|
+
self._is_disposed = False
|
|
64
|
+
self._waiting = False
|
|
65
|
+
self._dispose_event = asyncio.Event()
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def period(self) -> timedelta:
|
|
69
|
+
"""Get the current period as a timedelta."""
|
|
70
|
+
return timedelta(seconds=self._period_seconds)
|
|
71
|
+
|
|
72
|
+
@period.setter
|
|
73
|
+
def period(self, value: Union[timedelta, float]) -> None:
|
|
74
|
+
"""
|
|
75
|
+
Set a new period (supported in .NET 8+).
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
value: New time interval between ticks.
|
|
79
|
+
"""
|
|
80
|
+
new_period = value.total_seconds() if isinstance(value, timedelta) else float(value)
|
|
81
|
+
|
|
82
|
+
if new_period <= 0:
|
|
83
|
+
raise ValueError("Period must be positive")
|
|
84
|
+
|
|
85
|
+
self._period_seconds = new_period
|
|
86
|
+
|
|
87
|
+
async def wait_for_next_tick_async(
|
|
88
|
+
self, cancellation_token: Optional[asyncio.Event] = None
|
|
89
|
+
) -> bool:
|
|
90
|
+
"""
|
|
91
|
+
Wait asynchronously for the next timer tick.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
cancellation_token: Optional cancellation token to stop waiting.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
True if the timer ticked successfully, False if canceled or disposed.
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
RuntimeError: If multiple consumers try to wait simultaneously.
|
|
101
|
+
|
|
102
|
+
Note:
|
|
103
|
+
- Only one call to this method may be in flight at any time
|
|
104
|
+
- If a tick occurred while no one was waiting, the next call
|
|
105
|
+
will complete immediately
|
|
106
|
+
- The timer starts when the instance is created, not when first called
|
|
107
|
+
"""
|
|
108
|
+
if self._is_disposed:
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
if self._waiting:
|
|
112
|
+
raise RuntimeError("Only one consumer may wait on PeriodicTimer at a time")
|
|
113
|
+
|
|
114
|
+
self._waiting = True
|
|
115
|
+
try:
|
|
116
|
+
# Calculate next tick time
|
|
117
|
+
self._tick_count += 1
|
|
118
|
+
next_tick_time = self._start_time + (self._tick_count * self._period_seconds)
|
|
119
|
+
|
|
120
|
+
# Calculate wait time
|
|
121
|
+
current_time = time.monotonic()
|
|
122
|
+
wait_time = max(0, next_tick_time - current_time)
|
|
123
|
+
|
|
124
|
+
# If we're behind schedule, complete immediately
|
|
125
|
+
if wait_time == 0:
|
|
126
|
+
return not self._is_disposed
|
|
127
|
+
|
|
128
|
+
# Create tasks for waiting
|
|
129
|
+
tasks: List[asyncio.Task[Any]] = [asyncio.create_task(asyncio.sleep(wait_time))]
|
|
130
|
+
|
|
131
|
+
# Add dispose event task
|
|
132
|
+
dispose_task = asyncio.create_task(self._dispose_event.wait())
|
|
133
|
+
tasks.append(dispose_task)
|
|
134
|
+
|
|
135
|
+
# Add cancellation token if provided
|
|
136
|
+
if cancellation_token:
|
|
137
|
+
cancel_task = asyncio.create_task(cancellation_token.wait())
|
|
138
|
+
tasks.append(cancel_task)
|
|
139
|
+
|
|
140
|
+
# Wait for first task to complete
|
|
141
|
+
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
142
|
+
|
|
143
|
+
# Cancel pending tasks
|
|
144
|
+
for task in pending:
|
|
145
|
+
task.cancel()
|
|
146
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
147
|
+
await task
|
|
148
|
+
|
|
149
|
+
# Check what completed
|
|
150
|
+
if dispose_task in done or self._is_disposed:
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
return not (cancellation_token and cancel_task in done)
|
|
154
|
+
|
|
155
|
+
finally:
|
|
156
|
+
self._waiting = False
|
|
157
|
+
|
|
158
|
+
def dispose(self) -> None:
|
|
159
|
+
"""
|
|
160
|
+
Dispose of the timer and release resources.
|
|
161
|
+
|
|
162
|
+
This will cause any pending wait_for_next_tick_async() calls to return False.
|
|
163
|
+
"""
|
|
164
|
+
if not self._is_disposed:
|
|
165
|
+
self._is_disposed = True
|
|
166
|
+
self._dispose_event.set()
|
|
167
|
+
|
|
168
|
+
def __enter__(self) -> PeriodicTimer:
|
|
169
|
+
"""Context manager entry."""
|
|
170
|
+
return self
|
|
171
|
+
|
|
172
|
+
def __exit__(
|
|
173
|
+
self,
|
|
174
|
+
exc_type: Optional[type],
|
|
175
|
+
exc_val: Optional[BaseException],
|
|
176
|
+
exc_tb: Optional[TracebackType],
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Context manager exit - ensures disposal."""
|
|
179
|
+
self.dispose()
|
|
180
|
+
|
|
181
|
+
def __del__(self) -> None:
|
|
182
|
+
"""Ensure timer is disposed when garbage collected."""
|
|
183
|
+
self.dispose()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class PeriodicTimerSync:
|
|
187
|
+
"""
|
|
188
|
+
Synchronous version of PeriodicTimer for non-async contexts.
|
|
189
|
+
|
|
190
|
+
Provides similar functionality but with blocking wait methods.
|
|
191
|
+
Useful when async is not available or desired.
|
|
192
|
+
|
|
193
|
+
Example:
|
|
194
|
+
timer = PeriodicTimerSync(1.0/60) # 60 FPS
|
|
195
|
+
try:
|
|
196
|
+
while timer.wait_for_next_tick():
|
|
197
|
+
render_frame()
|
|
198
|
+
finally:
|
|
199
|
+
timer.dispose()
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
def __init__(self, period: Union[timedelta, float]):
|
|
203
|
+
"""
|
|
204
|
+
Initialize a new synchronous PeriodicTimer.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
period: Time interval between ticks. Can be timedelta or float (seconds).
|
|
208
|
+
"""
|
|
209
|
+
if isinstance(period, timedelta):
|
|
210
|
+
self._period_seconds = period.total_seconds()
|
|
211
|
+
else:
|
|
212
|
+
self._period_seconds = float(period)
|
|
213
|
+
|
|
214
|
+
if self._period_seconds <= 0:
|
|
215
|
+
raise ValueError("Period must be positive")
|
|
216
|
+
|
|
217
|
+
self._start_time = time.monotonic()
|
|
218
|
+
self._tick_count = 0
|
|
219
|
+
self._is_disposed = False
|
|
220
|
+
self._waiting = False
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def period(self) -> timedelta:
|
|
224
|
+
"""Get the current period as a timedelta."""
|
|
225
|
+
return timedelta(seconds=self._period_seconds)
|
|
226
|
+
|
|
227
|
+
@period.setter
|
|
228
|
+
def period(self, value: Union[timedelta, float]) -> None:
|
|
229
|
+
"""Set a new period."""
|
|
230
|
+
new_period = value.total_seconds() if isinstance(value, timedelta) else float(value)
|
|
231
|
+
|
|
232
|
+
if new_period <= 0:
|
|
233
|
+
raise ValueError("Period must be positive")
|
|
234
|
+
|
|
235
|
+
self._period_seconds = new_period
|
|
236
|
+
|
|
237
|
+
def wait_for_next_tick(self, timeout: Optional[float] = None) -> bool:
|
|
238
|
+
"""
|
|
239
|
+
Wait synchronously for the next timer tick.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
timeout: Optional timeout in seconds. None means wait indefinitely.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
True if the timer ticked successfully, False if timed out or disposed.
|
|
246
|
+
|
|
247
|
+
Raises:
|
|
248
|
+
RuntimeError: If multiple consumers try to wait simultaneously.
|
|
249
|
+
"""
|
|
250
|
+
if self._is_disposed:
|
|
251
|
+
return False
|
|
252
|
+
|
|
253
|
+
if self._waiting:
|
|
254
|
+
raise RuntimeError("Only one consumer may wait on PeriodicTimer at a time")
|
|
255
|
+
|
|
256
|
+
self._waiting = True
|
|
257
|
+
try:
|
|
258
|
+
# Calculate next tick time
|
|
259
|
+
self._tick_count += 1
|
|
260
|
+
next_tick_time = self._start_time + (self._tick_count * self._period_seconds)
|
|
261
|
+
|
|
262
|
+
# Calculate wait time
|
|
263
|
+
current_time = time.monotonic()
|
|
264
|
+
wait_time = max(0, next_tick_time - current_time)
|
|
265
|
+
|
|
266
|
+
# Apply timeout if specified
|
|
267
|
+
if timeout is not None:
|
|
268
|
+
wait_time = min(wait_time, timeout)
|
|
269
|
+
|
|
270
|
+
# If we need to wait, sleep
|
|
271
|
+
if wait_time > 0:
|
|
272
|
+
time.sleep(wait_time)
|
|
273
|
+
|
|
274
|
+
# Check if we're disposed or timed out
|
|
275
|
+
if self._is_disposed:
|
|
276
|
+
return False
|
|
277
|
+
|
|
278
|
+
if timeout is not None:
|
|
279
|
+
actual_time = time.monotonic()
|
|
280
|
+
if actual_time < next_tick_time:
|
|
281
|
+
return False # Timed out
|
|
282
|
+
|
|
283
|
+
return True
|
|
284
|
+
|
|
285
|
+
finally:
|
|
286
|
+
self._waiting = False
|
|
287
|
+
|
|
288
|
+
def dispose(self) -> None:
|
|
289
|
+
"""Dispose of the timer."""
|
|
290
|
+
self._is_disposed = True
|
|
291
|
+
|
|
292
|
+
def __enter__(self) -> PeriodicTimerSync:
|
|
293
|
+
"""Context manager entry."""
|
|
294
|
+
return self
|
|
295
|
+
|
|
296
|
+
def __exit__(
|
|
297
|
+
self,
|
|
298
|
+
exc_type: Optional[type],
|
|
299
|
+
exc_val: Optional[BaseException],
|
|
300
|
+
exc_tb: Optional[TracebackType],
|
|
301
|
+
) -> None:
|
|
302
|
+
"""Context manager exit."""
|
|
303
|
+
self.dispose()
|
|
@@ -6,19 +6,25 @@ Main entry point for the RocketWelder SDK.
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
8
|
import logging
|
|
9
|
+
import queue
|
|
9
10
|
import threading
|
|
10
|
-
from typing import TYPE_CHECKING, Any, Callable
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Callable, List, Optional, Union
|
|
11
12
|
|
|
12
13
|
import numpy as np
|
|
13
14
|
|
|
14
15
|
from .connection_string import ConnectionMode, ConnectionString, Protocol
|
|
15
16
|
from .controllers import DuplexShmController, IController, OneWayShmController
|
|
17
|
+
from .opencv_controller import OpenCvController
|
|
16
18
|
|
|
17
19
|
if TYPE_CHECKING:
|
|
20
|
+
import numpy.typing as npt
|
|
21
|
+
|
|
18
22
|
from .gst_metadata import GstMetadata
|
|
19
23
|
|
|
20
|
-
#
|
|
21
|
-
Mat = np.
|
|
24
|
+
# Use numpy array type for Mat - OpenCV Mat is essentially a numpy array
|
|
25
|
+
Mat = npt.NDArray[np.uint8]
|
|
26
|
+
else:
|
|
27
|
+
Mat = np.ndarray # type: ignore[misc]
|
|
22
28
|
|
|
23
29
|
# Module logger
|
|
24
30
|
logger = logging.getLogger(__name__)
|
|
@@ -31,7 +37,7 @@ class RocketWelderClient:
|
|
|
31
37
|
Provides a unified interface for different connection types and protocols.
|
|
32
38
|
"""
|
|
33
39
|
|
|
34
|
-
def __init__(self, connection: str
|
|
40
|
+
def __init__(self, connection: Union[str, ConnectionString]):
|
|
35
41
|
"""
|
|
36
42
|
Initialize the RocketWelder client.
|
|
37
43
|
|
|
@@ -43,9 +49,17 @@ class RocketWelderClient:
|
|
|
43
49
|
else:
|
|
44
50
|
self._connection = connection
|
|
45
51
|
|
|
46
|
-
self._controller: IController
|
|
52
|
+
self._controller: Optional[IController] = None
|
|
47
53
|
self._lock = threading.Lock()
|
|
48
54
|
|
|
55
|
+
# Preview support
|
|
56
|
+
self._preview_enabled = (
|
|
57
|
+
self._connection.parameters.get("preview", "false").lower() == "true"
|
|
58
|
+
)
|
|
59
|
+
self._preview_queue: queue.Queue[Optional[Mat]] = queue.Queue(maxsize=2) # type: ignore[valid-type] # Small buffer
|
|
60
|
+
self._preview_window_name = "RocketWelder Preview"
|
|
61
|
+
self._original_callback: Any = None
|
|
62
|
+
|
|
49
63
|
@property
|
|
50
64
|
def connection(self) -> ConnectionString:
|
|
51
65
|
"""Get the connection configuration."""
|
|
@@ -57,7 +71,7 @@ class RocketWelderClient:
|
|
|
57
71
|
with self._lock:
|
|
58
72
|
return self._controller is not None and self._controller.is_running
|
|
59
73
|
|
|
60
|
-
def get_metadata(self) -> GstMetadata
|
|
74
|
+
def get_metadata(self) -> Optional[GstMetadata]:
|
|
61
75
|
"""
|
|
62
76
|
Get the current GStreamer metadata.
|
|
63
77
|
|
|
@@ -71,8 +85,8 @@ class RocketWelderClient:
|
|
|
71
85
|
|
|
72
86
|
def start(
|
|
73
87
|
self,
|
|
74
|
-
on_frame: Callable[[Mat], None]
|
|
75
|
-
cancellation_token: threading.Event
|
|
88
|
+
on_frame: Union[Callable[[Mat], None], Callable[[Mat, Mat], None]], # type: ignore[valid-type]
|
|
89
|
+
cancellation_token: Optional[threading.Event] = None,
|
|
76
90
|
) -> None:
|
|
77
91
|
"""
|
|
78
92
|
Start receiving/processing video frames.
|
|
@@ -97,11 +111,55 @@ class RocketWelderClient:
|
|
|
97
111
|
self._controller = DuplexShmController(self._connection)
|
|
98
112
|
else:
|
|
99
113
|
self._controller = OneWayShmController(self._connection)
|
|
114
|
+
elif self._connection.protocol in (Protocol.FILE, Protocol.MJPEG):
|
|
115
|
+
self._controller = OpenCvController(self._connection)
|
|
100
116
|
else:
|
|
101
117
|
raise ValueError(f"Unsupported protocol: {self._connection.protocol}")
|
|
102
118
|
|
|
119
|
+
# If preview is enabled, wrap the callback to capture frames
|
|
120
|
+
if self._preview_enabled:
|
|
121
|
+
self._original_callback = on_frame
|
|
122
|
+
|
|
123
|
+
# Determine if duplex or one-way
|
|
124
|
+
if self._connection.connection_mode == ConnectionMode.DUPLEX:
|
|
125
|
+
|
|
126
|
+
def preview_wrapper_duplex(input_frame: Mat, output_frame: Mat) -> None: # type: ignore[valid-type]
|
|
127
|
+
# Call original callback
|
|
128
|
+
on_frame(input_frame, output_frame) # type: ignore[call-arg]
|
|
129
|
+
# Queue the OUTPUT frame for preview
|
|
130
|
+
try:
|
|
131
|
+
self._preview_queue.put_nowait(output_frame.copy()) # type: ignore[attr-defined]
|
|
132
|
+
except queue.Full:
|
|
133
|
+
# Drop oldest frame if queue is full
|
|
134
|
+
try:
|
|
135
|
+
self._preview_queue.get_nowait()
|
|
136
|
+
self._preview_queue.put_nowait(output_frame.copy()) # type: ignore[attr-defined]
|
|
137
|
+
except queue.Empty:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
actual_callback = preview_wrapper_duplex
|
|
141
|
+
else:
|
|
142
|
+
|
|
143
|
+
def preview_wrapper_oneway(frame: Mat) -> None: # type: ignore[valid-type]
|
|
144
|
+
# Call original callback
|
|
145
|
+
on_frame(frame) # type: ignore[call-arg]
|
|
146
|
+
# Queue frame for preview
|
|
147
|
+
try:
|
|
148
|
+
self._preview_queue.put_nowait(frame.copy()) # type: ignore[attr-defined]
|
|
149
|
+
except queue.Full:
|
|
150
|
+
# Drop oldest frame if queue is full
|
|
151
|
+
try:
|
|
152
|
+
self._preview_queue.get_nowait()
|
|
153
|
+
self._preview_queue.put_nowait(frame.copy()) # type: ignore[attr-defined]
|
|
154
|
+
except queue.Empty:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
actual_callback = preview_wrapper_oneway # type: ignore[assignment]
|
|
158
|
+
else:
|
|
159
|
+
actual_callback = on_frame # type: ignore[assignment]
|
|
160
|
+
|
|
103
161
|
# Start the controller
|
|
104
|
-
self._controller.start(
|
|
162
|
+
self._controller.start(actual_callback, cancellation_token) # type: ignore[arg-type]
|
|
105
163
|
logger.info("RocketWelder client started with %s", self._connection)
|
|
106
164
|
|
|
107
165
|
def stop(self) -> None:
|
|
@@ -110,8 +168,83 @@ class RocketWelderClient:
|
|
|
110
168
|
if self._controller:
|
|
111
169
|
self._controller.stop()
|
|
112
170
|
self._controller = None
|
|
171
|
+
|
|
172
|
+
# Signal preview to stop if enabled
|
|
173
|
+
if self._preview_enabled:
|
|
174
|
+
self._preview_queue.put(None) # Sentinel value
|
|
175
|
+
|
|
113
176
|
logger.info("RocketWelder client stopped")
|
|
114
177
|
|
|
178
|
+
def show(self, cancellation_token: Optional[threading.Event] = None) -> None:
|
|
179
|
+
"""
|
|
180
|
+
Display preview frames in a window (main thread only).
|
|
181
|
+
|
|
182
|
+
This method should be called from the main thread after start().
|
|
183
|
+
- If preview=true: blocks and displays frames until stopped or 'q' pressed
|
|
184
|
+
- If preview=false or not set: returns immediately
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
cancellation_token: Optional cancellation token to stop preview
|
|
188
|
+
|
|
189
|
+
Example:
|
|
190
|
+
client = RocketWelderClient("file:///video.mp4?preview=true")
|
|
191
|
+
client.start(process_frame)
|
|
192
|
+
client.show() # Blocks and shows preview
|
|
193
|
+
client.stop()
|
|
194
|
+
"""
|
|
195
|
+
if not self._preview_enabled:
|
|
196
|
+
# No preview requested, return immediately
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
import cv2
|
|
201
|
+
except ImportError:
|
|
202
|
+
logger.warning("OpenCV not available, cannot show preview")
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
logger.info("Starting preview display in main thread")
|
|
206
|
+
|
|
207
|
+
# Create window
|
|
208
|
+
cv2.namedWindow(self._preview_window_name, cv2.WINDOW_NORMAL)
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
while True:
|
|
212
|
+
# Check for cancellation
|
|
213
|
+
if cancellation_token and cancellation_token.is_set():
|
|
214
|
+
break
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
# Get frame with timeout
|
|
218
|
+
frame = self._preview_queue.get(timeout=0.1)
|
|
219
|
+
|
|
220
|
+
# Check for stop sentinel
|
|
221
|
+
if frame is None:
|
|
222
|
+
break
|
|
223
|
+
|
|
224
|
+
# Display frame
|
|
225
|
+
cv2.imshow(self._preview_window_name, frame)
|
|
226
|
+
|
|
227
|
+
# Process window events and check for 'q' key
|
|
228
|
+
key = cv2.waitKey(1) & 0xFF
|
|
229
|
+
if key == ord("q"):
|
|
230
|
+
logger.info("User pressed 'q', stopping preview")
|
|
231
|
+
break
|
|
232
|
+
|
|
233
|
+
except queue.Empty:
|
|
234
|
+
# No frame available, check if still running
|
|
235
|
+
if not self.is_running:
|
|
236
|
+
break
|
|
237
|
+
# Process window events even without new frame
|
|
238
|
+
if cv2.waitKey(1) & 0xFF == ord("q"):
|
|
239
|
+
logger.info("User pressed 'q', stopping preview")
|
|
240
|
+
break
|
|
241
|
+
|
|
242
|
+
finally:
|
|
243
|
+
# Clean up window
|
|
244
|
+
cv2.destroyWindow(self._preview_window_name)
|
|
245
|
+
cv2.waitKey(1) # Process pending events
|
|
246
|
+
logger.info("Preview display stopped")
|
|
247
|
+
|
|
115
248
|
def __enter__(self) -> RocketWelderClient:
|
|
116
249
|
"""Context manager entry."""
|
|
117
250
|
return self
|
|
@@ -120,6 +253,110 @@ class RocketWelderClient:
|
|
|
120
253
|
"""Context manager exit."""
|
|
121
254
|
self.stop()
|
|
122
255
|
|
|
256
|
+
@classmethod
|
|
257
|
+
def from_connection_string(cls, connection_string: str) -> RocketWelderClient:
|
|
258
|
+
"""
|
|
259
|
+
Create a client from a connection string.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
connection_string: Connection string (e.g., 'shm://buffer?mode=Duplex')
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
Configured RocketWelderClient instance
|
|
266
|
+
"""
|
|
267
|
+
return cls(connection_string)
|
|
268
|
+
|
|
269
|
+
@classmethod
|
|
270
|
+
def from_args(cls, args: List[str]) -> RocketWelderClient:
|
|
271
|
+
"""
|
|
272
|
+
Create a client from command line arguments.
|
|
273
|
+
|
|
274
|
+
Checks in order:
|
|
275
|
+
1. First positional argument from args
|
|
276
|
+
2. CONNECTION_STRING environment variable
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
args: Command line arguments (typically sys.argv)
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
Configured RocketWelderClient instance
|
|
283
|
+
|
|
284
|
+
Raises:
|
|
285
|
+
ValueError: If no connection string is found
|
|
286
|
+
"""
|
|
287
|
+
import os
|
|
288
|
+
|
|
289
|
+
# Check for positional argument (skip script name if present)
|
|
290
|
+
connection_string = None
|
|
291
|
+
for arg in args[1:] if len(args) > 0 and args[0].endswith(".py") else args:
|
|
292
|
+
if not arg.startswith("-"):
|
|
293
|
+
connection_string = arg
|
|
294
|
+
break
|
|
295
|
+
|
|
296
|
+
# Fall back to environment variable
|
|
297
|
+
if not connection_string:
|
|
298
|
+
connection_string = os.environ.get("CONNECTION_STRING")
|
|
299
|
+
|
|
300
|
+
if not connection_string:
|
|
301
|
+
raise ValueError(
|
|
302
|
+
"No connection string provided. "
|
|
303
|
+
"Provide as argument or set CONNECTION_STRING environment variable"
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
return cls(connection_string)
|
|
307
|
+
|
|
308
|
+
@classmethod
|
|
309
|
+
def from_(cls, *args: Any, **kwargs: Any) -> RocketWelderClient:
|
|
310
|
+
"""
|
|
311
|
+
Create a client with automatic configuration detection.
|
|
312
|
+
|
|
313
|
+
This is the most convenient factory method that:
|
|
314
|
+
1. Checks kwargs for 'args' parameter (command line arguments)
|
|
315
|
+
2. Checks args for command line arguments
|
|
316
|
+
3. Falls back to CONNECTION_STRING environment variable
|
|
317
|
+
|
|
318
|
+
Examples:
|
|
319
|
+
client = RocketWelderClient.from_() # Uses env var
|
|
320
|
+
client = RocketWelderClient.from_(sys.argv) # Uses command line
|
|
321
|
+
client = RocketWelderClient.from_(args=sys.argv) # Named param
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
Configured RocketWelderClient instance
|
|
325
|
+
|
|
326
|
+
Raises:
|
|
327
|
+
ValueError: If no connection string is found
|
|
328
|
+
"""
|
|
329
|
+
import os
|
|
330
|
+
|
|
331
|
+
# Check kwargs first
|
|
332
|
+
argv = kwargs.get("args")
|
|
333
|
+
|
|
334
|
+
# Then check positional args
|
|
335
|
+
if not argv and args:
|
|
336
|
+
# If first arg looks like sys.argv (list), use it
|
|
337
|
+
if isinstance(args[0], list):
|
|
338
|
+
argv = args[0]
|
|
339
|
+
# If first arg is a string, treat it as connection string
|
|
340
|
+
elif isinstance(args[0], str):
|
|
341
|
+
return cls(args[0])
|
|
342
|
+
|
|
343
|
+
# Try to get from command line args if provided
|
|
344
|
+
if argv:
|
|
345
|
+
try:
|
|
346
|
+
return cls.from_args(argv)
|
|
347
|
+
except ValueError:
|
|
348
|
+
pass # Fall through to env var check
|
|
349
|
+
|
|
350
|
+
# Fall back to environment variable
|
|
351
|
+
connection_string = os.environ.get("CONNECTION_STRING")
|
|
352
|
+
if connection_string:
|
|
353
|
+
return cls(connection_string)
|
|
354
|
+
|
|
355
|
+
raise ValueError(
|
|
356
|
+
"No connection string provided. "
|
|
357
|
+
"Provide as argument or set CONNECTION_STRING environment variable"
|
|
358
|
+
)
|
|
359
|
+
|
|
123
360
|
@classmethod
|
|
124
361
|
def create_oneway_shm(
|
|
125
362
|
cls,
|