rocket-welder-sdk 1.1.26__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.
@@ -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
- # Type alias for OpenCV Mat
21
- Mat = np.ndarray[Any, Any]
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 | ConnectionString):
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 | None = None
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 | None:
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] | Callable[[Mat, Mat], None],
75
- cancellation_token: threading.Event | None = None,
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(on_frame, cancellation_token) # type: ignore[arg-type]
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,