rocket-welder-sdk 1.1.36.dev14__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 +95 -0
- rocket_welder_sdk/bytes_size.py +234 -0
- rocket_welder_sdk/connection_string.py +291 -0
- rocket_welder_sdk/controllers.py +831 -0
- rocket_welder_sdk/external_controls/__init__.py +30 -0
- rocket_welder_sdk/external_controls/contracts.py +100 -0
- rocket_welder_sdk/external_controls/contracts_old.py +105 -0
- rocket_welder_sdk/frame_metadata.py +138 -0
- rocket_welder_sdk/gst_metadata.py +411 -0
- rocket_welder_sdk/high_level/__init__.py +54 -0
- rocket_welder_sdk/high_level/client.py +235 -0
- rocket_welder_sdk/high_level/connection_strings.py +331 -0
- rocket_welder_sdk/high_level/data_context.py +169 -0
- rocket_welder_sdk/high_level/frame_sink_factory.py +118 -0
- rocket_welder_sdk/high_level/schema.py +195 -0
- rocket_welder_sdk/high_level/transport_protocol.py +238 -0
- rocket_welder_sdk/keypoints_protocol.py +642 -0
- rocket_welder_sdk/opencv_controller.py +278 -0
- rocket_welder_sdk/periodic_timer.py +303 -0
- rocket_welder_sdk/py.typed +2 -0
- rocket_welder_sdk/rocket_welder_client.py +497 -0
- rocket_welder_sdk/segmentation_result.py +420 -0
- rocket_welder_sdk/session_id.py +238 -0
- rocket_welder_sdk/transport/__init__.py +31 -0
- rocket_welder_sdk/transport/frame_sink.py +122 -0
- rocket_welder_sdk/transport/frame_source.py +74 -0
- rocket_welder_sdk/transport/nng_transport.py +197 -0
- rocket_welder_sdk/transport/stream_transport.py +193 -0
- rocket_welder_sdk/transport/tcp_transport.py +154 -0
- rocket_welder_sdk/transport/unix_socket_transport.py +339 -0
- rocket_welder_sdk/ui/__init__.py +48 -0
- rocket_welder_sdk/ui/controls.py +362 -0
- rocket_welder_sdk/ui/icons.py +21628 -0
- rocket_welder_sdk/ui/ui_events_projection.py +226 -0
- rocket_welder_sdk/ui/ui_service.py +358 -0
- rocket_welder_sdk/ui/value_types.py +72 -0
- rocket_welder_sdk-1.1.36.dev14.dist-info/METADATA +845 -0
- rocket_welder_sdk-1.1.36.dev14.dist-info/RECORD +40 -0
- rocket_welder_sdk-1.1.36.dev14.dist-info/WHEEL +5 -0
- rocket_welder_sdk-1.1.36.dev14.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenCV-based controller for video file playback and network streams.
|
|
3
|
+
Provides support for file:// and mjpeg:// protocols.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
14
|
+
|
|
15
|
+
import cv2
|
|
16
|
+
import numpy as np
|
|
17
|
+
import numpy.typing as npt
|
|
18
|
+
|
|
19
|
+
from .connection_string import ConnectionMode, ConnectionString, Protocol
|
|
20
|
+
from .controllers import IController
|
|
21
|
+
from .gst_metadata import GstCaps, GstMetadata
|
|
22
|
+
from .periodic_timer import PeriodicTimerSync
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
Mat = npt.NDArray[np.uint8]
|
|
26
|
+
else:
|
|
27
|
+
Mat = np.ndarray # type: ignore[misc]
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class OpenCvController(IController):
|
|
33
|
+
"""
|
|
34
|
+
Controller for video sources using OpenCV VideoCapture.
|
|
35
|
+
|
|
36
|
+
Supports:
|
|
37
|
+
- File playback with optional looping
|
|
38
|
+
- MJPEG network streams over HTTP/TCP
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, connection: ConnectionString) -> None:
|
|
42
|
+
"""
|
|
43
|
+
Initialize the OpenCV controller.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
connection: Connection string configuration
|
|
47
|
+
"""
|
|
48
|
+
if not (connection.protocol == Protocol.FILE or bool(connection.protocol & Protocol.MJPEG)): # type: ignore[operator]
|
|
49
|
+
raise ValueError(
|
|
50
|
+
f"OpenCvController requires FILE or MJPEG protocol, got {connection.protocol}"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
self._connection = connection
|
|
54
|
+
self._capture: cv2.VideoCapture | None = None
|
|
55
|
+
self._metadata: GstMetadata | None = None
|
|
56
|
+
self._is_running = False
|
|
57
|
+
self._worker_thread: threading.Thread | None = None
|
|
58
|
+
self._cancellation_token: threading.Event | None = None
|
|
59
|
+
|
|
60
|
+
# Parse parameters for file protocol
|
|
61
|
+
self._loop = (
|
|
62
|
+
connection.protocol == Protocol.FILE
|
|
63
|
+
and connection.parameters.get("loop", "false").lower() == "true"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Note: Preview is now handled at the client level via show() method
|
|
67
|
+
# This avoids X11/WSL threading issues with OpenCV GUI functions
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def is_running(self) -> bool:
|
|
71
|
+
"""Check if the controller is running."""
|
|
72
|
+
return self._is_running
|
|
73
|
+
|
|
74
|
+
def get_metadata(self) -> GstMetadata | None:
|
|
75
|
+
"""Get the current video metadata."""
|
|
76
|
+
return self._metadata
|
|
77
|
+
|
|
78
|
+
def start(
|
|
79
|
+
self,
|
|
80
|
+
on_frame: (
|
|
81
|
+
Callable[[npt.NDArray[Any]], None]
|
|
82
|
+
| Callable[[npt.NDArray[Any], npt.NDArray[Any]], None]
|
|
83
|
+
),
|
|
84
|
+
cancellation_token: threading.Event | None = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""
|
|
87
|
+
Start processing video frames.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
on_frame: Callback for frame processing
|
|
91
|
+
cancellation_token: Optional cancellation token
|
|
92
|
+
"""
|
|
93
|
+
if self._is_running:
|
|
94
|
+
raise RuntimeError("Controller is already running")
|
|
95
|
+
|
|
96
|
+
self._is_running = True
|
|
97
|
+
self._cancellation_token = cancellation_token
|
|
98
|
+
|
|
99
|
+
# Get video source
|
|
100
|
+
source = self._get_source()
|
|
101
|
+
logger.info("Opening video source: %s (loop=%s)", source, self._loop)
|
|
102
|
+
|
|
103
|
+
# Create VideoCapture
|
|
104
|
+
self._capture = cv2.VideoCapture(source)
|
|
105
|
+
|
|
106
|
+
if not self._capture.isOpened():
|
|
107
|
+
self._capture.release()
|
|
108
|
+
self._capture = None
|
|
109
|
+
self._is_running = False
|
|
110
|
+
raise RuntimeError(f"Failed to open video source: {source}")
|
|
111
|
+
|
|
112
|
+
# Get video properties
|
|
113
|
+
width = int(self._capture.get(cv2.CAP_PROP_FRAME_WIDTH))
|
|
114
|
+
height = int(self._capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
115
|
+
fps = self._capture.get(cv2.CAP_PROP_FPS)
|
|
116
|
+
frame_count = int(self._capture.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
117
|
+
|
|
118
|
+
# Create metadata
|
|
119
|
+
caps = GstCaps.from_simple(width, height, "RGB")
|
|
120
|
+
self._metadata = GstMetadata(
|
|
121
|
+
type="video",
|
|
122
|
+
version="1.0",
|
|
123
|
+
caps=caps,
|
|
124
|
+
element_name=(
|
|
125
|
+
"file-capture" if self._connection.protocol == Protocol.FILE else "opencv-capture"
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
logger.info(
|
|
130
|
+
"Video source opened: %dx%d @ %.1ffps, %d frames", width, height, fps, frame_count
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Determine callback type and start worker thread
|
|
134
|
+
if self._connection.connection_mode == ConnectionMode.DUPLEX:
|
|
135
|
+
# For duplex mode with file/mjpeg, we allocate output but process as one-way
|
|
136
|
+
def duplex_wrapper(frame: npt.NDArray[Any]) -> None:
|
|
137
|
+
output = np.empty_like(frame)
|
|
138
|
+
on_frame(frame, output) # type: ignore[call-arg]
|
|
139
|
+
|
|
140
|
+
self._worker_thread = threading.Thread(
|
|
141
|
+
target=self._process_frames,
|
|
142
|
+
args=(duplex_wrapper, fps),
|
|
143
|
+
name=f"RocketWelder-OpenCV-{Path(source).stem}",
|
|
144
|
+
)
|
|
145
|
+
else:
|
|
146
|
+
self._worker_thread = threading.Thread(
|
|
147
|
+
target=self._process_frames,
|
|
148
|
+
args=(on_frame, fps),
|
|
149
|
+
name=f"RocketWelder-OpenCV-{Path(source).stem}",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
self._worker_thread.start()
|
|
153
|
+
|
|
154
|
+
def stop(self) -> None:
|
|
155
|
+
"""Stop the controller and clean up resources."""
|
|
156
|
+
if not self._is_running:
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
logger.debug("Stopping OpenCV controller")
|
|
160
|
+
self._is_running = False
|
|
161
|
+
|
|
162
|
+
# Wait for worker thread
|
|
163
|
+
if self._worker_thread and self._worker_thread.is_alive():
|
|
164
|
+
timeout_s = (self._connection.timeout_ms + 50) / 1000.0
|
|
165
|
+
self._worker_thread.join(timeout=timeout_s)
|
|
166
|
+
|
|
167
|
+
# Clean up capture
|
|
168
|
+
if self._capture:
|
|
169
|
+
self._capture.release()
|
|
170
|
+
self._capture = None
|
|
171
|
+
|
|
172
|
+
self._worker_thread = None
|
|
173
|
+
logger.info("Stopped OpenCV controller")
|
|
174
|
+
|
|
175
|
+
def _get_source(self) -> str:
|
|
176
|
+
"""
|
|
177
|
+
Get the video source string for OpenCV.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Source string for VideoCapture
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
FileNotFoundError: If file doesn't exist
|
|
184
|
+
ValueError: If file path is missing
|
|
185
|
+
"""
|
|
186
|
+
if self._connection.protocol == Protocol.FILE:
|
|
187
|
+
if not self._connection.file_path:
|
|
188
|
+
raise ValueError("File path is required for file protocol")
|
|
189
|
+
|
|
190
|
+
if not os.path.exists(self._connection.file_path):
|
|
191
|
+
raise FileNotFoundError(f"Video file not found: {self._connection.file_path}")
|
|
192
|
+
|
|
193
|
+
return self._connection.file_path
|
|
194
|
+
|
|
195
|
+
elif bool(self._connection.protocol & Protocol.MJPEG): # type: ignore[operator]
|
|
196
|
+
# Construct URL from host:port (no path support yet)
|
|
197
|
+
if bool(self._connection.protocol & Protocol.HTTP): # type: ignore[operator]
|
|
198
|
+
return f"http://{self._connection.host}:{self._connection.port}"
|
|
199
|
+
elif bool(self._connection.protocol & Protocol.TCP): # type: ignore[operator]
|
|
200
|
+
return f"tcp://{self._connection.host}:{self._connection.port}"
|
|
201
|
+
else:
|
|
202
|
+
return f"http://{self._connection.host}:{self._connection.port}"
|
|
203
|
+
|
|
204
|
+
else:
|
|
205
|
+
raise ValueError(f"Unsupported protocol: {self._connection.protocol}")
|
|
206
|
+
|
|
207
|
+
def _process_frames(self, on_frame: Callable[[npt.NDArray[Any]], None], fps: float) -> None:
|
|
208
|
+
"""
|
|
209
|
+
Process video frames in a loop.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
on_frame: Callback for each frame
|
|
213
|
+
fps: Frames per second for timing
|
|
214
|
+
"""
|
|
215
|
+
if not self._capture:
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
# Use PeriodicTimer for precise frame timing (especially important for file playback)
|
|
219
|
+
timer = None
|
|
220
|
+
if self._connection.protocol == Protocol.FILE and fps > 0:
|
|
221
|
+
# Create timer for file playback at specified FPS
|
|
222
|
+
timer = PeriodicTimerSync(1.0 / fps)
|
|
223
|
+
logger.debug("Using PeriodicTimer for file playback at %.1f FPS", fps)
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
while self._is_running:
|
|
227
|
+
if self._cancellation_token and self._cancellation_token.is_set():
|
|
228
|
+
break
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
# Read frame
|
|
232
|
+
ret, frame = self._capture.read()
|
|
233
|
+
|
|
234
|
+
if not ret:
|
|
235
|
+
if self._connection.protocol == Protocol.FILE and self._loop:
|
|
236
|
+
# Loop: Reset to beginning
|
|
237
|
+
self._capture.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
|
238
|
+
logger.debug("Looping video from beginning")
|
|
239
|
+
continue
|
|
240
|
+
elif self._connection.protocol == Protocol.FILE:
|
|
241
|
+
# File ended without loop
|
|
242
|
+
logger.info("Video file ended")
|
|
243
|
+
break
|
|
244
|
+
else:
|
|
245
|
+
# Network stream issue
|
|
246
|
+
logger.warning("Failed to read frame from stream")
|
|
247
|
+
time.sleep(0.01)
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
if hasattr(frame, "size") and frame.size == 0:
|
|
251
|
+
time.sleep(0.01)
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
# Process frame
|
|
255
|
+
on_frame(frame)
|
|
256
|
+
|
|
257
|
+
# Control frame rate for file playback using PeriodicTimer
|
|
258
|
+
if timer:
|
|
259
|
+
# Wait for next tick - this provides precise timing
|
|
260
|
+
if not timer.wait_for_next_tick():
|
|
261
|
+
# Timer disposed or timed out
|
|
262
|
+
break
|
|
263
|
+
elif self._connection.protocol != Protocol.FILE:
|
|
264
|
+
# For network streams, we process as fast as they arrive
|
|
265
|
+
# No artificial delay needed
|
|
266
|
+
pass
|
|
267
|
+
|
|
268
|
+
except Exception as e:
|
|
269
|
+
logger.error("Error processing frame: %s", e)
|
|
270
|
+
if not self._is_running:
|
|
271
|
+
break
|
|
272
|
+
time.sleep(0.1)
|
|
273
|
+
|
|
274
|
+
finally:
|
|
275
|
+
if timer:
|
|
276
|
+
timer.dispose()
|
|
277
|
+
|
|
278
|
+
self._is_running = False
|
|
@@ -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()
|