rocket-welder-sdk 1.1.27__py3-none-any.whl → 1.1.29__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.
@@ -11,9 +11,11 @@ from .bytes_size import BytesSize
11
11
  from .connection_string import ConnectionMode, ConnectionString, Protocol
12
12
  from .controllers import DuplexShmController, IController, OneWayShmController
13
13
  from .gst_metadata import GstCaps, GstMetadata
14
+ from .opencv_controller import OpenCvController
15
+ from .periodic_timer import PeriodicTimer, PeriodicTimerSync
14
16
  from .rocket_welder_client import RocketWelderClient
15
17
 
16
- # Alias for backward compatibility
18
+ # Alias for backward compatibility and README examples
17
19
  Client = RocketWelderClient
18
20
 
19
21
  __version__ = "1.1.0"
@@ -50,6 +52,10 @@ __all__ = [
50
52
  # Controllers
51
53
  "IController",
52
54
  "OneWayShmController",
55
+ "OpenCvController",
56
+ # Timers
57
+ "PeriodicTimer",
58
+ "PeriodicTimerSync",
53
59
  "Protocol",
54
60
  # Main client
55
61
  "RocketWelderClient",
@@ -21,6 +21,7 @@ class Protocol(Flag):
21
21
  MJPEG = auto() # Motion JPEG
22
22
  HTTP = auto() # HTTP protocol
23
23
  TCP = auto() # TCP protocol
24
+ FILE = auto() # File protocol
24
25
 
25
26
 
26
27
  class ConnectionMode(Enum):
@@ -43,12 +44,15 @@ class ConnectionString:
43
44
  - shm://buffer_name?size=256MB&metadata=4KB&mode=Duplex
44
45
  - mjpeg://192.168.1.100:8080
45
46
  - mjpeg+http://camera.local:80
47
+ - file:///path/to/video.mp4?loop=true
46
48
  """
47
49
 
48
50
  protocol: Protocol
49
51
  host: str | None = None
50
52
  port: int | None = None
51
53
  buffer_name: str | None = None
54
+ file_path: str | None = None
55
+ parameters: dict[str, str] = field(default_factory=dict)
52
56
  buffer_size: BytesSize = field(default_factory=lambda: BytesSize.parse("256MB"))
53
57
  metadata_size: BytesSize = field(default_factory=lambda: BytesSize.parse("4KB"))
54
58
  connection_mode: ConnectionMode = ConnectionMode.ONE_WAY
@@ -82,6 +86,8 @@ class ConnectionString:
82
86
  # Parse based on protocol type
83
87
  if protocol == Protocol.SHM:
84
88
  return cls._parse_shm(protocol, remainder)
89
+ elif protocol == Protocol.FILE:
90
+ return cls._parse_file(protocol, remainder)
85
91
  elif bool(protocol & Protocol.MJPEG): # type: ignore[operator]
86
92
  return cls._parse_mjpeg(protocol, remainder)
87
93
  else:
@@ -110,6 +116,7 @@ class ConnectionString:
110
116
  "mjpeg": Protocol.MJPEG,
111
117
  "http": Protocol.HTTP,
112
118
  "tcp": Protocol.TCP,
119
+ "file": Protocol.FILE,
113
120
  }
114
121
 
115
122
  protocol = protocol_map.get(protocol_str, Protocol.NONE)
@@ -157,6 +164,43 @@ class ConnectionString:
157
164
  timeout_ms=timeout_ms,
158
165
  )
159
166
 
167
+ @classmethod
168
+ def _parse_file(cls, protocol: Protocol, remainder: str) -> ConnectionString:
169
+ """Parse file protocol connection string."""
170
+ # Split file path and query parameters
171
+ if "?" in remainder:
172
+ file_path, query_string = remainder.split("?", 1)
173
+ params = cls._parse_query_params(query_string)
174
+ else:
175
+ file_path = remainder
176
+ params = {}
177
+
178
+ # Handle file:///absolute/path and file://relative/path
179
+ if not file_path.startswith("/"):
180
+ file_path = "/" + file_path
181
+
182
+ # Parse common parameters
183
+ connection_mode = ConnectionMode.ONE_WAY
184
+ timeout_ms = 5000
185
+
186
+ if "mode" in params:
187
+ mode_str = params["mode"].upper()
188
+ if mode_str == "DUPLEX":
189
+ connection_mode = ConnectionMode.DUPLEX
190
+ elif mode_str in ("ONEWAY", "ONE_WAY"):
191
+ connection_mode = ConnectionMode.ONE_WAY
192
+ if "timeout" in params:
193
+ with contextlib.suppress(ValueError):
194
+ timeout_ms = int(params["timeout"])
195
+
196
+ return cls(
197
+ protocol=protocol,
198
+ file_path=file_path,
199
+ parameters=params,
200
+ connection_mode=connection_mode,
201
+ timeout_ms=timeout_ms,
202
+ )
203
+
160
204
  @classmethod
161
205
  def _parse_mjpeg(cls, protocol: Protocol, remainder: str) -> ConnectionString:
162
206
  """Parse MJPEG connection string."""
@@ -195,6 +239,8 @@ class ConnectionString:
195
239
  protocol_parts = []
196
240
  if self.protocol & Protocol.SHM:
197
241
  protocol_parts.append("shm")
242
+ if self.protocol & Protocol.FILE:
243
+ protocol_parts.append("file")
198
244
  if self.protocol & Protocol.MJPEG:
199
245
  protocol_parts.append("mjpeg")
200
246
  if self.protocol & Protocol.HTTP:
@@ -215,6 +261,11 @@ class ConnectionString:
215
261
  params.append(f"timeout={self.timeout_ms}")
216
262
 
217
263
  return f"{protocol_str}://{self.buffer_name}?{'&'.join(params)}"
264
+ elif self.protocol == Protocol.FILE:
265
+ query_string = ""
266
+ if self.parameters:
267
+ query_string = "?" + "&".join(f"{k}={v}" for k, v in self.parameters.items())
268
+ return f"{protocol_str}://{self.file_path}{query_string}"
218
269
  else:
219
270
  return f"{protocol_str}://{self.host}:{self.port}"
220
271
 
@@ -9,18 +9,23 @@ import json
9
9
  import logging
10
10
  import threading
11
11
  from abc import ABC, abstractmethod
12
- from typing import Any, Callable
12
+ from typing import TYPE_CHECKING, Callable, Optional
13
13
 
14
14
  import numpy as np
15
15
  from zerobuffer import BufferConfig, Frame, Reader, Writer
16
16
  from zerobuffer.duplex import DuplexChannelFactory, IImmutableDuplexServer
17
+ from zerobuffer.duplex.server import ImmutableDuplexServer
17
18
  from zerobuffer.exceptions import WriterDeadException
18
19
 
19
20
  from .connection_string import ConnectionMode, ConnectionString, Protocol
20
21
  from .gst_metadata import GstCaps, GstMetadata
21
22
 
22
- # Type alias for OpenCV Mat
23
- Mat = np.ndarray[Any, Any]
23
+ if TYPE_CHECKING:
24
+ import numpy.typing as npt
25
+
26
+ Mat = npt.NDArray[np.uint8]
27
+ else:
28
+ Mat = np.ndarray # type: ignore[misc]
24
29
 
25
30
  # Module logger
26
31
  logger = logging.getLogger(__name__)
@@ -36,13 +41,15 @@ class IController(ABC):
36
41
  ...
37
42
 
38
43
  @abstractmethod
39
- def get_metadata(self) -> GstMetadata | None:
44
+ def get_metadata(self) -> Optional[GstMetadata]:
40
45
  """Get the current GStreamer metadata."""
41
46
  ...
42
47
 
43
48
  @abstractmethod
44
49
  def start(
45
- self, on_frame: Callable[[Mat], None], cancellation_token: threading.Event | None = None
50
+ self,
51
+ on_frame: Callable[[Mat], None], # type: ignore[valid-type]
52
+ cancellation_token: Optional[threading.Event] = None,
46
53
  ) -> None:
47
54
  """
48
55
  Start the controller with a frame callback.
@@ -80,24 +87,26 @@ class OneWayShmController(IController):
80
87
  )
81
88
 
82
89
  self._connection = connection
83
- self._reader: Reader | None = None
84
- self._gst_caps: GstCaps | None = None
85
- self._metadata: GstMetadata | None = None
90
+ self._reader: Optional[Reader] = None
91
+ self._gst_caps: Optional[GstCaps] = None
92
+ self._metadata: Optional[GstMetadata] = None
86
93
  self._is_running = False
87
- self._worker_thread: threading.Thread | None = None
88
- self._cancellation_token: threading.Event | None = None
94
+ self._worker_thread: Optional[threading.Thread] = None
95
+ self._cancellation_token: Optional[threading.Event] = None
89
96
 
90
97
  @property
91
98
  def is_running(self) -> bool:
92
99
  """Check if the controller is running."""
93
100
  return self._is_running
94
101
 
95
- def get_metadata(self) -> GstMetadata | None:
102
+ def get_metadata(self) -> Optional[GstMetadata]:
96
103
  """Get the current GStreamer metadata."""
97
104
  return self._metadata
98
105
 
99
106
  def start(
100
- self, on_frame: Callable[[Mat], None], cancellation_token: threading.Event | None = None
107
+ self,
108
+ on_frame: Callable[[Mat], None], # type: ignore[valid-type]
109
+ cancellation_token: Optional[threading.Event] = None,
101
110
  ) -> None:
102
111
  """
103
112
  Start receiving frames from shared memory.
@@ -161,7 +170,7 @@ class OneWayShmController(IController):
161
170
  self._worker_thread = None
162
171
  logger.info("Stopped controller for buffer '%s'", self._connection.buffer_name)
163
172
 
164
- def _process_frames(self, on_frame: Callable[[Mat], None]) -> None:
173
+ def _process_frames(self, on_frame: Callable[[Mat], None]) -> None: # type: ignore[valid-type]
165
174
  """
166
175
  Process frames from shared memory.
167
176
 
@@ -236,7 +245,7 @@ class OneWayShmController(IController):
236
245
  logger.error("Fatal error in frame processing loop: %s", e)
237
246
  self._is_running = False
238
247
 
239
- def _on_first_frame(self, on_frame: Callable[[Mat], None]) -> None:
248
+ def _on_first_frame(self, on_frame: Callable[[Mat], None]) -> None: # type: ignore[valid-type]
240
249
  """
241
250
  Process the first frame and extract metadata.
242
251
  Matches C# OnFirstFrame behavior - loops until valid frame received.
@@ -268,36 +277,14 @@ class OneWayShmController(IController):
268
277
  bytes(metadata_bytes[: min(100, len(metadata_bytes))]),
269
278
  )
270
279
 
271
- # Convert memoryview to bytes if needed
272
- if isinstance(metadata_bytes, memoryview):
273
- metadata_bytes = bytes(metadata_bytes)
274
-
275
- # Decode UTF-8
276
- metadata_str = metadata_bytes.decode("utf-8")
277
-
278
- # Check if metadata is empty or all zeros
279
- if not metadata_str or metadata_str == "\x00" * len(metadata_str):
280
- logger.warning("Metadata is empty or all zeros, skipping")
280
+ # Use helper method to parse metadata
281
+ metadata = self._parse_metadata_json(metadata_bytes)
282
+ if not metadata:
283
+ logger.warning("Failed to parse metadata, skipping")
281
284
  continue
282
285
 
283
- # Find the start of JSON (skip any null bytes at the beginning)
284
- json_start = metadata_str.find("{")
285
- if json_start == -1:
286
- logger.warning("No JSON found in metadata: %r", metadata_str[:100])
287
- continue
288
-
289
- if json_start > 0:
290
- logger.debug("Skipping %d bytes before JSON", json_start)
291
- metadata_str = metadata_str[json_start:]
292
-
293
- # Find the end of JSON (handle null padding)
294
- json_end = metadata_str.rfind("}")
295
- if json_end != -1 and json_end < len(metadata_str) - 1:
296
- metadata_str = metadata_str[: json_end + 1]
297
-
298
- metadata_json = json.loads(metadata_str)
299
- self._metadata = GstMetadata.from_json(metadata_json)
300
- self._gst_caps = self._metadata.caps
286
+ self._metadata = metadata
287
+ self._gst_caps = metadata.caps
301
288
  logger.info(
302
289
  "Received metadata from buffer '%s': %s",
303
290
  self._connection.buffer_name,
@@ -342,7 +329,7 @@ class OneWayShmController(IController):
342
329
  if not self._is_running:
343
330
  break
344
331
 
345
- def _create_mat_from_frame(self, frame: Frame) -> Mat | None:
332
+ def _create_mat_from_frame(self, frame: Frame) -> Optional[Mat]: # type: ignore[valid-type]
346
333
  """
347
334
  Create OpenCV Mat from frame data using GstCaps.
348
335
  Matches C# CreateMat behavior - creates Mat wrapping the data.
@@ -406,6 +393,40 @@ class OneWayShmController(IController):
406
393
 
407
394
  # Try common resolutions
408
395
  frame_size = len(frame.data)
396
+
397
+ # First, check if it's a perfect square (square frame)
398
+ import math
399
+
400
+ sqrt_size = math.sqrt(frame_size)
401
+ if sqrt_size == int(sqrt_size):
402
+ # Perfect square - assume square grayscale image
403
+ dimension = int(sqrt_size)
404
+ logger.info(
405
+ f"Frame size {frame_size} is a perfect square, assuming {dimension}x{dimension} grayscale"
406
+ )
407
+ data = np.frombuffer(frame.data, dtype=np.uint8)
408
+ return data.reshape((dimension, dimension)) # type: ignore[no-any-return]
409
+
410
+ # Also check for square RGB (size = width * height * 3)
411
+ if frame_size % 3 == 0:
412
+ pixels = frame_size // 3
413
+ sqrt_pixels = math.sqrt(pixels)
414
+ if sqrt_pixels == int(sqrt_pixels):
415
+ dimension = int(sqrt_pixels)
416
+ logger.info(f"Frame size {frame_size} suggests {dimension}x{dimension} RGB")
417
+ data = np.frombuffer(frame.data, dtype=np.uint8)
418
+ return data.reshape((dimension, dimension, 3)) # type: ignore[no-any-return]
419
+
420
+ # Check for square RGBA (size = width * height * 4)
421
+ if frame_size % 4 == 0:
422
+ pixels = frame_size // 4
423
+ sqrt_pixels = math.sqrt(pixels)
424
+ if sqrt_pixels == int(sqrt_pixels):
425
+ dimension = int(sqrt_pixels)
426
+ logger.info(f"Frame size {frame_size} suggests {dimension}x{dimension} RGBA")
427
+ data = np.frombuffer(frame.data, dtype=np.uint8)
428
+ return data.reshape((dimension, dimension, 4)) # type: ignore[no-any-return]
429
+
409
430
  common_resolutions = [
410
431
  (640, 480, 3), # VGA RGB
411
432
  (640, 480, 4), # VGA RGBA
@@ -440,7 +461,46 @@ class OneWayShmController(IController):
440
461
  logger.error("Failed to convert frame to Mat: %s", e)
441
462
  return None
442
463
 
443
- def _infer_caps_from_frame(self, mat: Mat) -> None:
464
+ def _parse_metadata_json(self, metadata_bytes: bytes | memoryview) -> GstMetadata | None:
465
+ """
466
+ Parse metadata JSON from bytes, handling null padding and boundaries.
467
+
468
+ Args:
469
+ metadata_bytes: Raw metadata bytes or memoryview
470
+
471
+ Returns:
472
+ GstMetadata object or None if parsing fails
473
+ """
474
+ try:
475
+ # Convert to string
476
+ if isinstance(metadata_bytes, memoryview):
477
+ metadata_bytes = bytes(metadata_bytes)
478
+ metadata_str = metadata_bytes.decode("utf-8")
479
+
480
+ # Find JSON boundaries (handle null padding)
481
+ json_start = metadata_str.find("{")
482
+ if json_start < 0:
483
+ logger.debug("No JSON found in metadata")
484
+ return None
485
+
486
+ json_end = metadata_str.rfind("}")
487
+ if json_end <= json_start:
488
+ logger.debug("Invalid JSON boundaries in metadata")
489
+ return None
490
+
491
+ # Extract JSON
492
+ metadata_str = metadata_str[json_start : json_end + 1]
493
+
494
+ # Parse JSON
495
+ metadata_json = json.loads(metadata_str)
496
+ metadata = GstMetadata.from_json(metadata_json)
497
+ return metadata
498
+
499
+ except Exception as e:
500
+ logger.debug("Failed to parse metadata JSON: %s", e)
501
+ return None
502
+
503
+ def _infer_caps_from_frame(self, mat: Mat) -> None: # type: ignore[valid-type]
444
504
  """
445
505
  Infer GStreamer caps from OpenCV Mat.
446
506
 
@@ -487,11 +547,11 @@ class DuplexShmController(IController):
487
547
  )
488
548
 
489
549
  self._connection = connection
490
- self._duplex_server: IImmutableDuplexServer | None = None
491
- self._gst_caps: GstCaps | None = None
492
- self._metadata: GstMetadata | None = None
550
+ self._duplex_server: Optional[ImmutableDuplexServer] = None
551
+ self._gst_caps: Optional[GstCaps] = None
552
+ self._metadata: Optional[GstMetadata] = None
493
553
  self._is_running = False
494
- self._on_frame_callback: Callable[[Mat, Mat], None] | None = None
554
+ self._on_frame_callback: Optional[Callable[[Mat, Mat], None]] = None # type: ignore[valid-type]
495
555
  self._frame_count = 0
496
556
 
497
557
  @property
@@ -499,14 +559,14 @@ class DuplexShmController(IController):
499
559
  """Check if the controller is running."""
500
560
  return self._is_running
501
561
 
502
- def get_metadata(self) -> GstMetadata | None:
562
+ def get_metadata(self) -> Optional[GstMetadata]:
503
563
  """Get the current GStreamer metadata."""
504
564
  return self._metadata
505
565
 
506
566
  def start(
507
567
  self,
508
- on_frame: Callable[[Mat, Mat], None], # type: ignore[override]
509
- cancellation_token: threading.Event | None = None,
568
+ on_frame: Callable[[Mat, Mat], None], # type: ignore[override,valid-type]
569
+ cancellation_token: Optional[threading.Event] = None,
510
570
  ) -> None:
511
571
  """
512
572
  Start duplex frame processing.
@@ -532,6 +592,11 @@ class DuplexShmController(IController):
532
592
  if not self._connection.buffer_name:
533
593
  raise ValueError("Buffer name is required for shared memory connection")
534
594
  timeout_seconds = self._connection.timeout_ms / 1000.0
595
+ logger.debug(
596
+ "Creating duplex server with timeout: %d ms (%.1f seconds)",
597
+ self._connection.timeout_ms,
598
+ timeout_seconds,
599
+ )
535
600
  factory = DuplexChannelFactory()
536
601
  self._duplex_server = factory.create_immutable_server(
537
602
  self._connection.buffer_name, config, timeout_seconds
@@ -563,6 +628,44 @@ class DuplexShmController(IController):
563
628
 
564
629
  logger.info("DuplexShmController stopped")
565
630
 
631
+ def _parse_metadata_json(self, metadata_bytes: bytes | memoryview) -> GstMetadata | None:
632
+ """
633
+ Parse metadata JSON from bytes, handling null padding and boundaries.
634
+
635
+ Args:
636
+ metadata_bytes: Raw metadata bytes or memoryview
637
+
638
+ Returns:
639
+ GstMetadata object or None if parsing fails
640
+ """
641
+ try:
642
+ # Convert to string
643
+ if isinstance(metadata_bytes, memoryview):
644
+ metadata_bytes = bytes(metadata_bytes)
645
+ metadata_str = metadata_bytes.decode("utf-8")
646
+
647
+ # Find JSON boundaries (handle null padding)
648
+ json_start = metadata_str.find("{")
649
+ if json_start < 0:
650
+ logger.debug("No JSON found in metadata")
651
+ return None
652
+
653
+ json_end = metadata_str.rfind("}")
654
+ if json_end <= json_start:
655
+ logger.debug("Invalid JSON boundaries in metadata")
656
+ return None
657
+
658
+ # Extract JSON
659
+ metadata_str = metadata_str[json_start : json_end + 1]
660
+
661
+ # Parse JSON
662
+ metadata_json = json.loads(metadata_str)
663
+ metadata = GstMetadata.from_json(metadata_json)
664
+ return metadata
665
+ except Exception as e:
666
+ logger.debug("Failed to parse metadata JSON: %s", e)
667
+ return None
668
+
566
669
  def _on_metadata(self, metadata_bytes: bytes | memoryview) -> None:
567
670
  """
568
671
  Handle metadata from duplex channel.
@@ -574,23 +677,20 @@ class DuplexShmController(IController):
574
677
  "_on_metadata called with %d bytes", len(metadata_bytes) if metadata_bytes else 0
575
678
  )
576
679
  try:
577
- # Convert memoryview to bytes if needed
578
- if isinstance(metadata_bytes, memoryview):
579
- metadata_bytes = bytes(metadata_bytes)
580
-
581
680
  # Log raw bytes for debugging
582
681
  logger.debug(
583
682
  "Raw metadata bytes (first 100): %r",
584
683
  metadata_bytes[: min(100, len(metadata_bytes))],
585
684
  )
586
685
 
587
- metadata_str = metadata_bytes.decode("utf-8")
588
- logger.debug("Decoded metadata string: %r", metadata_str[: min(200, len(metadata_str))])
589
-
590
- metadata_json = json.loads(metadata_str)
591
- self._metadata = GstMetadata.from_json(metadata_json)
592
- self._gst_caps = self._metadata.caps
593
- logger.info("Received metadata: %s", self._metadata)
686
+ # Use helper method to parse metadata
687
+ metadata = self._parse_metadata_json(metadata_bytes)
688
+ if metadata:
689
+ self._metadata = metadata
690
+ self._gst_caps = metadata.caps
691
+ logger.info("Received metadata: %s", self._metadata)
692
+ else:
693
+ logger.warning("Failed to parse metadata from buffer initialization")
594
694
  except Exception as e:
595
695
  logger.error("Failed to parse metadata: %s", e, exc_info=True)
596
696
 
@@ -614,6 +714,30 @@ class DuplexShmController(IController):
614
714
 
615
715
  self._frame_count += 1
616
716
 
717
+ # Try to read metadata if we don't have it yet
718
+ if (
719
+ self._metadata is None
720
+ and self._duplex_server
721
+ and self._duplex_server.request_reader
722
+ ):
723
+ try:
724
+ metadata_bytes = self._duplex_server.request_reader.get_metadata()
725
+ if metadata_bytes:
726
+ # Use helper method to parse metadata
727
+ metadata = self._parse_metadata_json(metadata_bytes)
728
+ if metadata:
729
+ self._metadata = metadata
730
+ self._gst_caps = metadata.caps
731
+ logger.info(
732
+ "Successfully read metadata from buffer '%s': %s",
733
+ self._connection.buffer_name,
734
+ self._gst_caps,
735
+ )
736
+ else:
737
+ logger.debug("Failed to parse metadata in frame processing")
738
+ except Exception as e:
739
+ logger.debug("Failed to read metadata in frame processing: %s", e)
740
+
617
741
  # Convert input frame to Mat
618
742
  input_mat = self._frame_to_mat(request_frame)
619
743
  if input_mat is None:
@@ -662,7 +786,7 @@ class DuplexShmController(IController):
662
786
  except Exception as e:
663
787
  logger.error("Error processing duplex frame: %s", e)
664
788
 
665
- def _frame_to_mat(self, frame: Frame) -> Mat | None:
789
+ def _frame_to_mat(self, frame: Frame) -> Optional[Mat]: # type: ignore[valid-type]
666
790
  """Convert frame to OpenCV Mat (reuse from OneWayShmController)."""
667
791
  # Implementation is same as OneWayShmController
668
792
  return OneWayShmController._create_mat_from_frame(self, frame) # type: ignore[arg-type]