rocket-welder-sdk 1.0.4__py3-none-any.whl → 1.1.0__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.
@@ -1,326 +0,0 @@
1
- """
2
- RocketWelder client implementation with zero-copy frame processing
3
-
4
- Mirrors the C# implementation with Python idioms.
5
- """
6
-
7
- import os
8
- import sys
9
- import json
10
- import logging
11
- import threading
12
- import struct
13
- from typing import Optional, Callable, Dict, Any
14
- from pathlib import Path
15
-
16
- import cv2
17
- import numpy as np
18
-
19
- # Add ZeroBuffer to path
20
- zerobuffer_path = Path("/mnt/d/source/modelingevolution/streamer/src/zerobuffer/python")
21
- if zerobuffer_path.exists() and str(zerobuffer_path) not in sys.path:
22
- sys.path.insert(0, str(zerobuffer_path))
23
-
24
- from zerobuffer import Reader, Writer, BufferConfig
25
- from zerobuffer.exceptions import WriterDeadException
26
-
27
- from .connection_string import ConnectionString, Protocol
28
- from .gst_caps import GstCaps
29
- from .gst_metadata import GstMetadata
30
- from .exceptions import RocketWelderException, ConnectionException
31
-
32
-
33
- class RocketWelderClient:
34
- """
35
- Client for RocketWelder video streaming services
36
-
37
- Provides zero-copy access to video frames from shared memory or network streams.
38
- """
39
-
40
- def __init__(self, connection_string: str, logger: Optional[logging.Logger] = None):
41
- """
42
- Initialize client with connection string
43
-
44
- Args:
45
- connection_string: Connection string in format protocol://host:port/path
46
- logger: Optional logger instance
47
- """
48
- if not connection_string:
49
- raise ValueError("Connection string cannot be empty")
50
-
51
- self._connection = ConnectionString.parse(connection_string)
52
- self._logger = logger or logging.getLogger(__name__)
53
- self._frame_callback: Optional[Callable[[np.ndarray], None]] = None
54
- self._running = False
55
- self._thread: Optional[threading.Thread] = None
56
- self._stop_event = threading.Event()
57
-
58
- # ZeroBuffer components
59
- self._reader: Optional[Reader] = None
60
- self._writer: Optional[Writer] = None
61
-
62
- # Cached video format from metadata
63
- self._video_format: Optional[GstCaps] = None
64
-
65
- @classmethod
66
- def from_args(cls, args: list) -> 'RocketWelderClient':
67
- """
68
- Create client from command line arguments
69
-
70
- Args:
71
- args: Command line arguments
72
-
73
- Returns:
74
- RocketWelderClient instance
75
- """
76
- # Check environment variable first
77
- connection_string = os.environ.get("CONNECTION_STRING")
78
-
79
- # Override with command line args if present
80
- if args:
81
- for arg in args:
82
- if (arg.startswith("shm://") or
83
- arg.startswith("mjpeg+http://") or
84
- arg.startswith("mjpeg+tcp://")):
85
- connection_string = arg
86
- break
87
-
88
- return cls(connection_string or "shm://default")
89
-
90
- @classmethod
91
- def from_config(cls, config: Dict[str, Any], logger: Optional[logging.Logger] = None) -> 'RocketWelderClient':
92
- """
93
- Create client from configuration dictionary
94
-
95
- Args:
96
- config: Configuration dictionary
97
- logger: Optional logger instance
98
-
99
- Returns:
100
- RocketWelderClient instance
101
- """
102
- connection_string = ConnectionString.from_config(config)
103
- return cls(str(connection_string), logger)
104
-
105
- @classmethod
106
- def from_environment(cls) -> 'RocketWelderClient':
107
- """
108
- Create client from CONNECTION_STRING environment variable
109
-
110
- Returns:
111
- RocketWelderClient instance
112
- """
113
- connection_string = os.environ.get("CONNECTION_STRING", "shm://default")
114
- return cls(connection_string)
115
-
116
- @property
117
- def connection(self) -> ConnectionString:
118
- """Get connection string configuration"""
119
- return self._connection
120
-
121
- @property
122
- def is_running(self) -> bool:
123
- """Check if client is running"""
124
- return self._running
125
-
126
- def on_frame(self, callback: Callable[[np.ndarray], None]) -> None:
127
- """
128
- Set callback for frame processing
129
-
130
- Args:
131
- callback: Function to call with each frame (receives np.ndarray)
132
- """
133
- if callback is None:
134
- raise ValueError("Callback cannot be None")
135
- self._frame_callback = callback
136
-
137
- def start(self) -> None:
138
- """Start frame processing"""
139
- if self._running:
140
- return
141
-
142
- if self._frame_callback is None:
143
- raise RuntimeError("Frame callback must be set before starting")
144
-
145
- self._running = True
146
- self._stop_event.clear()
147
-
148
- # Start processing thread based on protocol
149
- if self._connection.protocol == Protocol.SHM:
150
- self._thread = threading.Thread(target=self._process_shared_memory)
151
- elif self._connection.protocol == (Protocol.MJPEG | Protocol.HTTP):
152
- self._thread = threading.Thread(target=self._process_mjpeg_http)
153
- elif self._connection.protocol == (Protocol.MJPEG | Protocol.TCP):
154
- self._thread = threading.Thread(target=self._process_mjpeg_tcp)
155
- else:
156
- raise NotImplementedError(f"Protocol {self._connection.protocol} not supported")
157
-
158
- self._thread.daemon = True
159
- self._thread.start()
160
-
161
- def stop(self) -> None:
162
- """Stop frame processing"""
163
- if not self._running:
164
- return
165
-
166
- self._running = False
167
- self._stop_event.set()
168
-
169
- if self._thread:
170
- self._thread.join(timeout=5.0)
171
- self._thread = None
172
-
173
- if self._reader:
174
- self._reader.close()
175
- self._reader = None
176
-
177
- if self._writer:
178
- self._writer.close()
179
- self._writer = None
180
-
181
- def _process_shared_memory(self) -> None:
182
- """Process frames from shared memory (zero-copy)"""
183
- try:
184
- buffer_name = self._connection.buffer_name or "default"
185
- buffer_size = self._connection.buffer_size
186
- metadata_size = self._connection.metadata_size
187
-
188
- config = BufferConfig(
189
- metadata_size=metadata_size,
190
- payload_size=buffer_size
191
- )
192
-
193
- # Create reader - this creates the shared memory buffer
194
- self._reader = Reader(buffer_name, config, logger=self._logger)
195
-
196
- if self._connection.mode == "duplex":
197
- self._logger.warning("Duplex mode not fully implemented yet, operating in read-only mode")
198
-
199
- self._logger.debug(
200
- "Created shared memory buffer: %s (size: %d, metadata: %d)",
201
- buffer_name, buffer_size, metadata_size
202
- )
203
-
204
- while not self._stop_event.is_set():
205
- try:
206
- # Read frame from shared memory (zero-copy)
207
- frame = self._reader.read_frame(timeout=1.0)
208
-
209
- if frame is None or not frame.is_valid:
210
- self._logger.info("No valid frame read, waiting for next frame")
211
- continue
212
-
213
- # Use context manager for proper Frame disposal (RAII)
214
- with frame:
215
- # Parse metadata on first frame or when not yet parsed
216
- if self._video_format is None:
217
- self._parse_metadata()
218
-
219
- if self._video_format is None:
220
- raise RuntimeError("No video format detected")
221
-
222
- # Create numpy array from frame data (zero-copy)
223
- # frame.data is a memoryview that directly points to shared memory
224
- mat = self._video_format.create_mat(frame.data)
225
-
226
- # Call frame callback with zero-copy array
227
- self._frame_callback(mat)
228
-
229
- except WriterDeadException:
230
- self._logger.warning("Writer process died")
231
- break
232
- except Exception as e:
233
- self._logger.error("Error reading from shared memory: %s", e)
234
- if not self._stop_event.wait(0.1):
235
- continue
236
-
237
- except Exception as e:
238
- self._logger.error("Error in shared memory processing: %s", e)
239
- raise
240
-
241
- def _parse_metadata(self) -> None:
242
- """Parse video format from metadata"""
243
- try:
244
- metadata_view = self._reader.get_metadata()
245
- if metadata_view is None or len(metadata_view) == 0:
246
- return
247
-
248
- # Python's get_metadata() returns raw JSON without any prefixes
249
- json_str = bytes(metadata_view).decode('utf-8')
250
-
251
- # Deserialize to strongly-typed GstMetadata
252
- metadata = GstMetadata.from_json(json_str)
253
-
254
- # Use the already-parsed GstCaps from metadata
255
- self._video_format = metadata.caps
256
- self._logger.info(
257
- "Parsed metadata - Type: %s, Version: %s, Element: %s, Format: %s",
258
- metadata.type,
259
- metadata.version,
260
- metadata.element_name,
261
- self._video_format
262
- )
263
-
264
- except Exception as e:
265
- self._logger.warning("Failed to parse metadata: %s", e)
266
-
267
- def _process_mjpeg_http(self) -> None:
268
- """Process MJPEG stream over HTTP"""
269
- url = f"http://{self._connection.host}:{self._connection.port or 80}"
270
- if self._connection.path:
271
- url += f"/{self._connection.path}"
272
- self._process_mjpeg_stream(url)
273
-
274
- def _process_mjpeg_tcp(self) -> None:
275
- """Process MJPEG stream over TCP"""
276
- url = f"tcp://{self._connection.host}:{self._connection.port or 8080}"
277
- if self._connection.path:
278
- url += f"/{self._connection.path}"
279
- self._process_mjpeg_stream(url)
280
-
281
- def _process_mjpeg_stream(self, url: str) -> None:
282
- """Process MJPEG stream using OpenCV VideoCapture"""
283
- try:
284
- # Use OpenCV VideoCapture which can handle MJPEG streams directly
285
- cap = cv2.VideoCapture(url)
286
-
287
- if not cap.isOpened():
288
- raise ConnectionException(f"Failed to open video stream: {url}")
289
-
290
- self._logger.info("Opened MJPEG stream: %s", url)
291
-
292
- while not self._stop_event.is_set():
293
- try:
294
- # Read frame from stream
295
- ret, frame = cap.read()
296
-
297
- if ret and frame is not None:
298
- # Process the frame
299
- self._frame_callback(frame)
300
- else:
301
- # Small delay if no frame available
302
- if self._stop_event.wait(0.01):
303
- break
304
-
305
- except Exception as e:
306
- self._logger.error("Error reading from MJPEG stream: %s", e)
307
- if self._stop_event.wait(0.1):
308
- break
309
-
310
- cap.release()
311
-
312
- except Exception as e:
313
- self._logger.error("Error in MJPEG processing: %s", e)
314
- raise
315
-
316
- def __enter__(self):
317
- """Context manager entry"""
318
- return self
319
-
320
- def __exit__(self, exc_type, exc_val, exc_tb):
321
- """Context manager exit"""
322
- self.stop()
323
-
324
- def __del__(self):
325
- """Cleanup on deletion"""
326
- self.stop()
@@ -1,190 +0,0 @@
1
- """
2
- Connection string parsing for RocketWelder SDK
3
-
4
- Mirrors the C# implementation with Python idioms.
5
- """
6
-
7
- from dataclasses import dataclass
8
- from enum import Flag, auto
9
- from typing import Optional, Dict, Any
10
- from urllib.parse import urlparse, parse_qs
11
- import os
12
-
13
-
14
- class Protocol(Flag):
15
- """Protocol flags that can be combined using bitwise operations"""
16
- NONE = 0
17
- SHM = auto()
18
- MJPEG = auto()
19
- HTTP = auto()
20
- TCP = auto()
21
-
22
- def __add__(self, other):
23
- """Support + operator as bitwise OR"""
24
- return self | other
25
-
26
-
27
- @dataclass(frozen=True)
28
- class ConnectionString:
29
- """
30
- Readonly connection string configuration
31
-
32
- Mirrors C# readonly record struct with IParsable interface.
33
- """
34
- protocol: Protocol
35
- host: Optional[str] = None
36
- port: Optional[int] = None
37
- path: Optional[str] = None
38
- buffer_name: Optional[str] = None
39
- buffer_size: int = 10485760 # 10MB default
40
- metadata_size: int = 65536 # 64KB default
41
- mode: str = "oneway" # "oneway" or "duplex"
42
-
43
- @classmethod
44
- def parse(cls, s: str, provider=None) -> 'ConnectionString':
45
- """
46
- Parse connection string (equivalent to IParsable<T>.Parse in C#)
47
-
48
- Args:
49
- s: Connection string in format protocol://host:port/path?params
50
- provider: Not used, kept for API compatibility with C#
51
-
52
- Returns:
53
- ConnectionString instance
54
-
55
- Raises:
56
- ValueError: If connection string format is invalid
57
- """
58
- if not s:
59
- raise ValueError("Connection string cannot be empty")
60
-
61
- # Handle environment variable
62
- if s.startswith("$"):
63
- env_var = s[1:]
64
- s = os.environ.get(env_var, "")
65
- if not s:
66
- raise ValueError(f"Environment variable {env_var} not set")
67
-
68
- # Parse URL
69
- parsed = urlparse(s)
70
-
71
- # Determine protocol
72
- protocol = Protocol.NONE
73
- if parsed.scheme == "shm":
74
- protocol = Protocol.SHM
75
- elif parsed.scheme == "mjpeg+http":
76
- protocol = Protocol.MJPEG | Protocol.HTTP
77
- elif parsed.scheme == "mjpeg+tcp":
78
- protocol = Protocol.MJPEG | Protocol.TCP
79
- elif parsed.scheme == "http":
80
- protocol = Protocol.HTTP
81
- elif parsed.scheme == "tcp":
82
- protocol = Protocol.TCP
83
- else:
84
- raise ValueError(f"Unknown protocol: {parsed.scheme}")
85
-
86
- # Parse query parameters
87
- params = parse_qs(parsed.query) if parsed.query else {}
88
-
89
- # Extract components based on protocol
90
- host = None
91
- port = None
92
- path = None
93
- buffer_name = None
94
-
95
- if protocol == Protocol.SHM:
96
- # For SHM, the netloc or path becomes the buffer name
97
- buffer_name = parsed.netloc or parsed.path.lstrip("/") or "default"
98
- else:
99
- # For network protocols
100
- host = parsed.hostname
101
- port = parsed.port
102
- path = parsed.path.lstrip("/") if parsed.path else None
103
-
104
- # Extract additional parameters
105
- buffer_size = int(params.get("buffer_size", [10485760])[0])
106
- metadata_size = int(params.get("metadata_size", [65536])[0])
107
- mode = params.get("mode", ["oneway"])[0]
108
-
109
- return cls(
110
- protocol=protocol,
111
- host=host,
112
- port=port,
113
- path=path,
114
- buffer_name=buffer_name,
115
- buffer_size=buffer_size,
116
- metadata_size=metadata_size,
117
- mode=mode
118
- )
119
-
120
- @classmethod
121
- def try_parse(cls, s: str, provider=None) -> tuple[bool, Optional['ConnectionString']]:
122
- """
123
- Try to parse connection string (equivalent to IParsable<T>.TryParse in C#)
124
-
125
- Args:
126
- s: Connection string to parse
127
- provider: Not used, kept for API compatibility
128
-
129
- Returns:
130
- Tuple of (success, ConnectionString or None)
131
- """
132
- try:
133
- result = cls.parse(s, provider)
134
- return True, result
135
- except (ValueError, KeyError):
136
- return False, None
137
-
138
- @classmethod
139
- def from_config(cls, config: Dict[str, Any]) -> 'ConnectionString':
140
- """
141
- Create from configuration dictionary (mirrors IConfiguration in C#)
142
-
143
- Args:
144
- config: Configuration dictionary
145
-
146
- Returns:
147
- ConnectionString instance
148
- """
149
- # Try to get connection string from various sources
150
- connection_string = (
151
- config.get("CONNECTION_STRING") or
152
- config.get("RocketWelder", {}).get("ConnectionString") or
153
- config.get("ConnectionString") or
154
- os.environ.get("CONNECTION_STRING")
155
- )
156
-
157
- if connection_string:
158
- return cls.parse(connection_string)
159
-
160
- # Build from components
161
- protocol_str = config.get("RocketWelder", {}).get("Protocol", "shm")
162
- host = config.get("RocketWelder", {}).get("Host")
163
- port = config.get("RocketWelder", {}).get("Port")
164
- path = config.get("RocketWelder", {}).get("Path") or config.get("RocketWelder", {}).get("BufferName")
165
-
166
- if protocol_str == "shm":
167
- connection_string = f"shm://{path or 'default'}"
168
- elif host:
169
- port_part = f":{port}" if port else ""
170
- path_part = f"/{path}" if path else ""
171
- connection_string = f"{protocol_str}://{host}{port_part}{path_part}"
172
- else:
173
- connection_string = "shm://default"
174
-
175
- return cls.parse(connection_string)
176
-
177
- def __str__(self) -> str:
178
- """String representation of connection string"""
179
- if self.protocol == Protocol.SHM:
180
- return f"shm://{self.buffer_name or 'default'}?buffer_size={self.buffer_size}&metadata_size={self.metadata_size}&mode={self.mode}"
181
- elif self.protocol == (Protocol.MJPEG | Protocol.HTTP):
182
- port_part = f":{self.port}" if self.port else ""
183
- path_part = f"/{self.path}" if self.path else ""
184
- return f"mjpeg+http://{self.host}{port_part}{path_part}"
185
- elif self.protocol == (Protocol.MJPEG | Protocol.TCP):
186
- port_part = f":{self.port}" if self.port else ""
187
- path_part = f"/{self.path}" if self.path else ""
188
- return f"mjpeg+tcp://{self.host}{port_part}{path_part}"
189
- else:
190
- return f"{self.protocol.name.lower()}://{self.host}:{self.port}/{self.path}"
@@ -1,23 +0,0 @@
1
- """
2
- Exception classes for RocketWelder SDK
3
- """
4
-
5
-
6
- class RocketWelderException(Exception):
7
- """Base exception for RocketWelder SDK"""
8
- pass
9
-
10
-
11
- class ConnectionException(RocketWelderException):
12
- """Exception raised for connection errors"""
13
- pass
14
-
15
-
16
- class ProtocolException(RocketWelderException):
17
- """Exception raised for protocol-specific errors"""
18
- pass
19
-
20
-
21
- class BufferException(RocketWelderException):
22
- """Exception raised for buffer-related errors"""
23
- pass