puda-drivers 0.0.8__py3-none-any.whl → 0.0.9__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,4 @@
1
+ from .camera import CameraController, list_cameras
2
+
3
+ __all__ = ["CameraController", "list_cameras"]
4
+
@@ -0,0 +1,434 @@
1
+ """
2
+ Camera controller for image and video capture.
3
+
4
+ This module provides a Python interface for controlling cameras and webcams
5
+ to capture images and videos for laboratory automation applications using OpenCV.
6
+ """
7
+
8
+ import logging
9
+ import time
10
+ import threading
11
+ from datetime import datetime
12
+ from typing import Optional, Union, List, Tuple
13
+ from pathlib import Path
14
+ import cv2
15
+ import numpy as np
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def list_cameras(max_index: int = 10) -> List[Tuple[int, bool, Optional[tuple[int, int]]]]:
21
+ """
22
+ Lists available cameras on the system by testing camera indices.
23
+
24
+ This is a utility function that can be used independently of any controller instance.
25
+ It's useful for discovering available cameras before initializing a controller.
26
+
27
+ Args:
28
+ max_index: Maximum camera index to test (default: 10). The function will test
29
+ indices from 0 to max_index-1.
30
+
31
+ Returns:
32
+ List of tuples, where each tuple contains (index, is_available, resolution).
33
+ resolution is a (width, height) tuple if available, None otherwise.
34
+ """
35
+ available_cameras = []
36
+
37
+ for index in range(max_index):
38
+ cap = cv2.VideoCapture(index)
39
+ if cap.isOpened():
40
+ # Try to read a frame to confirm it's actually working
41
+ ret, _ = cap.read()
42
+ if ret:
43
+ # Get resolution
44
+ width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
45
+ height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
46
+ resolution = (width, height) if width > 0 and height > 0 else None
47
+ available_cameras.append((index, resolution))
48
+ cap.release()
49
+ else:
50
+ cap.release()
51
+
52
+ return available_cameras
53
+
54
+
55
+ class CameraController:
56
+ """
57
+ Controller for cameras and webcams using OpenCV.
58
+
59
+ This class provides methods for capturing images and videos from cameras.
60
+ Images can be returned as numpy arrays and optionally saved to the captures folder.
61
+ Videos can be recorded for a specified duration or controlled manually with start/stop.
62
+
63
+ Attributes:
64
+ camera_index: Index or identifier for the camera device
65
+ resolution: Camera resolution as (width, height) tuple
66
+ captures_folder: Path to the folder where captured images and videos are saved
67
+ """
68
+
69
+ DEFAULT_CAPTURES_FOLDER = "captures"
70
+
71
+ def __init__(
72
+ self,
73
+ camera_index: Union[int, str] = 0,
74
+ resolution: Optional[tuple[int, int]] = None,
75
+ captures_folder: Union[str, Path, None] = None,
76
+ ):
77
+ """
78
+ Initialize the camera controller.
79
+
80
+ Args:
81
+ camera_index: Camera device index (0 for default) or device path/identifier
82
+ resolution: Optional resolution as (width, height) tuple
83
+ captures_folder: Path to folder for saving captured images.
84
+ Defaults to "captures" in the current working directory.
85
+ """
86
+ self.camera_index = camera_index
87
+ self.resolution = resolution
88
+ self.captures_folder = Path(captures_folder) if captures_folder else Path(self.DEFAULT_CAPTURES_FOLDER)
89
+ self._logger = logging.getLogger(__name__)
90
+ self._camera: Optional[cv2.VideoCapture] = None
91
+ self._is_connected = False
92
+
93
+ # Video recording state
94
+ self._video_writer: Optional[cv2.VideoWriter] = None
95
+ self._is_recording = False
96
+ self._video_file_path: Optional[Path] = None
97
+ self._fps: float = 30.0 # Default FPS for video recording
98
+ self._recording_thread: Optional[threading.Thread] = None
99
+ self._stop_recording_event: Optional[threading.Event] = None
100
+
101
+ # Create captures folder if it doesn't exist
102
+ self.captures_folder.mkdir(parents=True, exist_ok=True)
103
+
104
+ self._logger.info(
105
+ "Camera Controller initialized with camera_index='%s', resolution=%s, captures_folder='%s'",
106
+ camera_index,
107
+ resolution,
108
+ self.captures_folder,
109
+ )
110
+
111
+ def connect(self) -> None:
112
+ """
113
+ Connect to the camera device.
114
+
115
+ Raises:
116
+ IOError: If camera connection fails
117
+ """
118
+ if self._is_connected:
119
+ self._logger.warning("Camera already connected. Disconnecting and reconnecting...")
120
+ self.disconnect()
121
+
122
+ self._logger.info("Connecting to camera %s...", self.camera_index)
123
+
124
+ try:
125
+ self._camera = cv2.VideoCapture(self.camera_index)
126
+
127
+ if not self._camera.isOpened():
128
+ raise IOError(f"Could not open camera {self.camera_index}")
129
+
130
+ # Set resolution if specified
131
+ if self.resolution:
132
+ width, height = self.resolution
133
+ self._camera.set(cv2.CAP_PROP_FRAME_WIDTH, width)
134
+ self._camera.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
135
+ self._logger.info("Resolution set to %dx%d", width, height)
136
+
137
+ self._is_connected = True
138
+ self._logger.info("Successfully connected to camera %s", self.camera_index)
139
+
140
+ except Exception as e:
141
+ self._camera = None
142
+ self._is_connected = False
143
+ self._logger.error("Error connecting to camera %s: %s", self.camera_index, e)
144
+ raise IOError(f"Error connecting to camera {self.camera_index}: {e}") from e
145
+
146
+ def disconnect(self) -> None:
147
+ """
148
+ Disconnect from the camera device.
149
+ """
150
+ # Stop any ongoing video recording before disconnecting
151
+ if self._is_recording:
152
+ self._logger.warning("Stopping video recording before disconnecting...")
153
+ self.stop_video_recording()
154
+
155
+ if self._camera is not None:
156
+ self._logger.info("Disconnecting from camera %s...", self.camera_index)
157
+ self._camera.release()
158
+ self._camera = None
159
+ self._is_connected = False
160
+ self._logger.info("Camera disconnected")
161
+ else:
162
+ self._logger.warning("Camera already disconnected or was never connected")
163
+
164
+ @property
165
+ def is_connected(self) -> bool:
166
+ """
167
+ Check if the camera is currently connected.
168
+
169
+ Returns:
170
+ True if connected, False otherwise
171
+ """
172
+ return self._is_connected and self._camera is not None and self._camera.isOpened()
173
+
174
+ def capture_image(
175
+ self,
176
+ save: bool = False,
177
+ filename: Optional[Union[str, Path]] = None
178
+ ) -> np.ndarray:
179
+ """
180
+ Capture a single image from the camera.
181
+
182
+ Args:
183
+ save: If True, save the image to the captures folder
184
+ filename: Optional filename for the saved image. If not provided and save=True,
185
+ a timestamped filename will be generated. If provided without extension,
186
+ .jpg will be added.
187
+
188
+ Returns:
189
+ Captured image as a numpy array (BGR format)
190
+
191
+ Raises:
192
+ IOError: If camera is not connected or capture fails
193
+ """
194
+ if not self.is_connected:
195
+ raise IOError("Camera is not connected. Call connect() first.")
196
+
197
+ self._logger.info("Capturing image...")
198
+
199
+ ret, frame = self._camera.read()
200
+
201
+ if not ret or frame is None:
202
+ self._logger.error("Failed to capture frame from camera")
203
+ raise IOError("Failed to capture frame from camera")
204
+
205
+ self._logger.info("Image captured successfully (shape: %s)", frame.shape)
206
+
207
+ # Save image if requested
208
+ if save:
209
+ if filename is None:
210
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
211
+ filename = f"capture_{timestamp}.jpg"
212
+
213
+ file_path = Path(filename)
214
+ # If filename doesn't have an extension, add .jpg
215
+ if not file_path.suffix:
216
+ file_path = file_path.with_suffix(".jpg")
217
+
218
+ # If filename is not absolute, save to captures folder
219
+ if not file_path.is_absolute():
220
+ file_path = self.captures_folder / file_path
221
+
222
+ # Ensure parent directory exists
223
+ file_path.parent.mkdir(parents=True, exist_ok=True)
224
+
225
+ cv2.imwrite(str(file_path), frame)
226
+ self._logger.info("Image saved to %s", file_path)
227
+
228
+ return frame
229
+
230
+ def set_resolution(self, width: int, height: int) -> None:
231
+ """
232
+ Set the camera resolution.
233
+
234
+ Args:
235
+ width: Image width in pixels
236
+ height: Image height in pixels
237
+ """
238
+ self.resolution = (width, height)
239
+ self._logger.info("Resolution set to %dx%d", width, height)
240
+
241
+ # Apply resolution to camera if connected
242
+ if self.is_connected:
243
+ self._camera.set(cv2.CAP_PROP_FRAME_WIDTH, width)
244
+ self._camera.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
245
+ self._logger.info("Resolution applied to camera")
246
+
247
+ def start_video_recording(
248
+ self,
249
+ filename: Optional[Union[str, Path]] = None,
250
+ fps: Optional[float] = None
251
+ ) -> Path:
252
+ """
253
+ Start recording a video.
254
+
255
+ Args:
256
+ filename: Optional filename for the video. If not provided, a timestamped
257
+ filename will be generated. If provided without extension, .mp4 will be added.
258
+ fps: Optional frames per second for the video. Defaults to 30.0 if not specified.
259
+
260
+ Returns:
261
+ Path to the video file where recording is being saved
262
+
263
+ Raises:
264
+ IOError: If camera is not connected or recording fails to start
265
+ ValueError: If already recording
266
+ """
267
+ if not self.is_connected:
268
+ raise IOError("Camera is not connected. Call connect() first.")
269
+
270
+ if self._is_recording:
271
+ raise ValueError("Video recording is already in progress. Call stop_video_recording() first.")
272
+
273
+ # Generate filename if not provided
274
+ if filename is None:
275
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
276
+ filename = f"video_{timestamp}.mp4"
277
+
278
+ file_path = Path(filename)
279
+ # If filename doesn't have an extension, add .mp4
280
+ if not file_path.suffix:
281
+ file_path = file_path.with_suffix(".mp4")
282
+
283
+ # If filename is not absolute, save to captures folder
284
+ if not file_path.is_absolute():
285
+ file_path = self.captures_folder / file_path
286
+
287
+ # Ensure parent directory exists
288
+ file_path.parent.mkdir(parents=True, exist_ok=True)
289
+
290
+ # Get current camera resolution
291
+ width = int(self._camera.get(cv2.CAP_PROP_FRAME_WIDTH))
292
+ height = int(self._camera.get(cv2.CAP_PROP_FRAME_HEIGHT))
293
+
294
+ # Use provided FPS or default
295
+ fps = fps if fps is not None else self._fps
296
+
297
+ # Define codec and create VideoWriter
298
+ fourcc = cv2.VideoWriter_fourcc(*'mp4v')
299
+ self._video_writer = cv2.VideoWriter(str(file_path), fourcc, fps, (width, height))
300
+
301
+ if not self._video_writer.isOpened():
302
+ self._video_writer = None
303
+ raise IOError(f"Failed to initialize video writer for {file_path}")
304
+
305
+ self._is_recording = True
306
+ self._video_file_path = file_path
307
+ self._fps = fps
308
+
309
+ # Start background thread to continuously capture frames
310
+ self._stop_recording_event = threading.Event()
311
+ self._recording_thread = threading.Thread(target=self._capture_frames_loop, daemon=True)
312
+ self._recording_thread.start()
313
+
314
+ self._logger.info("Started video recording to %s (FPS: %.1f, Resolution: %dx%d)",
315
+ file_path, fps, width, height)
316
+
317
+ return file_path
318
+
319
+ def _capture_frames_loop(self) -> None:
320
+ """
321
+ Internal method that continuously captures frames and writes them to the video.
322
+ Runs in a background thread while recording is active.
323
+ """
324
+ frame_interval = 1.0 / self._fps if self._fps > 0 else 0.033 # Default to ~30 FPS
325
+
326
+ while self._is_recording and not self._stop_recording_event.is_set():
327
+ if self._video_writer is None or self._camera is None:
328
+ break
329
+
330
+ ret, frame = self._camera.read()
331
+ if ret and frame is not None:
332
+ self._video_writer.write(frame)
333
+
334
+ time.sleep(frame_interval)
335
+
336
+ self._logger.debug("Frame capture loop stopped")
337
+
338
+ def stop_video_recording(self) -> Optional[Path]:
339
+ """
340
+ Stop recording a video.
341
+
342
+ Returns:
343
+ Path to the saved video file, or None if no recording was in progress
344
+
345
+ Raises:
346
+ IOError: If video writer fails to release
347
+ """
348
+ if not self._is_recording:
349
+ self._logger.warning("No video recording in progress")
350
+ return None
351
+
352
+ self._logger.info("Stopping video recording...")
353
+
354
+ # Signal the recording thread to stop
355
+ self._is_recording = False
356
+ if self._stop_recording_event is not None:
357
+ self._stop_recording_event.set()
358
+
359
+ # Wait for the recording thread to finish
360
+ if self._recording_thread is not None and self._recording_thread.is_alive():
361
+ self._recording_thread.join(timeout=2.0)
362
+
363
+ # Release video writer
364
+ if self._video_writer is not None:
365
+ self._video_writer.release()
366
+ self._video_writer = None
367
+
368
+ file_path = self._video_file_path
369
+ self._video_file_path = None
370
+ self._recording_thread = None
371
+ self._stop_recording_event = None
372
+
373
+ if file_path and file_path.exists():
374
+ self._logger.info("Video saved to %s", file_path)
375
+ else:
376
+ self._logger.warning("Video file may not have been saved correctly")
377
+
378
+ return file_path
379
+
380
+ def record_video(
381
+ self,
382
+ duration_seconds: float,
383
+ filename: Optional[Union[str, Path]] = None,
384
+ fps: Optional[float] = None
385
+ ) -> Path:
386
+ """
387
+ Record a video for a specified duration.
388
+
389
+ Args:
390
+ duration_seconds: Duration of the video in seconds
391
+ filename: Optional filename for the video. If not provided, a timestamped
392
+ filename will be generated. If provided without extension, .mp4 will be added.
393
+ fps: Optional frames per second for the video. Defaults to 30.0 if not specified.
394
+
395
+ Returns:
396
+ Path to the saved video file
397
+
398
+ Raises:
399
+ IOError: If camera is not connected or recording fails
400
+ ValueError: If duration is not positive
401
+ """
402
+ if not self.is_connected:
403
+ raise IOError("Camera is not connected. Call connect() first.")
404
+
405
+ if duration_seconds <= 0:
406
+ raise ValueError(f"Duration must be positive, got {duration_seconds}")
407
+
408
+ # Start recording
409
+ file_path = self.start_video_recording(filename=filename, fps=fps)
410
+
411
+ self._logger.info("Recording video for %.2f seconds...", duration_seconds)
412
+
413
+ start_time = time.time()
414
+ frame_count = 0
415
+
416
+ try:
417
+ while time.time() - start_time < duration_seconds:
418
+ ret, frame = self._camera.read()
419
+ if ret and frame is not None:
420
+ self._video_writer.write(frame)
421
+ frame_count += 1
422
+ else:
423
+ self._logger.warning("Failed to read frame during video recording")
424
+ break
425
+ finally:
426
+ # Always stop recording, even if there was an error
427
+ self.stop_video_recording()
428
+
429
+ actual_duration = time.time() - start_time
430
+ self._logger.info("Video recording completed: %.2f seconds, %d frames (%.1f FPS actual)",
431
+ actual_duration, frame_count, frame_count / actual_duration if actual_duration > 0 else 0)
432
+
433
+ return file_path
434
+
@@ -8,16 +8,17 @@ This class demonstrates the integration of:
8
8
  """
9
9
 
10
10
  import time
11
- from typing import Optional, Dict, Tuple, Type
11
+ from typing import Optional, Dict, Tuple, Type, Union
12
12
  from puda_drivers.move import GCodeController, Deck
13
13
  from puda_drivers.core import Position
14
14
  from puda_drivers.transfer.liquid.sartorius import SartoriusController
15
15
  from puda_drivers.labware import StandardLabware
16
+ from puda_drivers.cv import CameraController
16
17
 
17
18
 
18
19
  class First:
19
20
  """
20
- First machine class integrating motion control, deck management, and liquid handling.
21
+ First machine class integrating motion control, deck management, liquid handling, and camera.
21
22
 
22
23
  The deck has 16 slots arranged in a 4x4 grid (A1-D4).
23
24
  Each slot's origin location is stored for absolute movement calculation.
@@ -31,6 +32,8 @@ class First:
31
32
  DEFAULT_SARTORIUS_PORT = "/dev/ttyUSB0"
32
33
  DEFAULT_SARTORIUS_BAUDRATE = 9600
33
34
 
35
+ DEFAULT_CAMERA_INDEX = 0
36
+
34
37
  # origin position of Z and A axes
35
38
  Z_ORIGIN = Position(x=0, y=0, z=0)
36
39
  A_ORIGIN = Position(x=60, y=0, a=0)
@@ -69,6 +72,7 @@ class First:
69
72
  self,
70
73
  qubot_port: Optional[str] = None,
71
74
  sartorius_port: Optional[str] = None,
75
+ camera_index: Optional[Union[int, str]] = None,
72
76
  axis_limits: Optional[Dict[str, Tuple[float, float]]] = None,
73
77
  ):
74
78
  """
@@ -77,6 +81,8 @@ class First:
77
81
  Args:
78
82
  qubot_port: Serial port for GCodeController (e.g., '/dev/ttyACM0')
79
83
  sartorius_port: Serial port for SartoriusController (e.g., '/dev/ttyUSB0')
84
+ camera_index: Camera device index (0 for default) or device path/identifier.
85
+ Defaults to 0.
80
86
  axis_limits: Dictionary mapping axis names to (min, max) limits.
81
87
  Defaults to DEFAULT_AXIS_LIMITS.
82
88
  """
@@ -97,15 +103,22 @@ class First:
97
103
  port_name=sartorius_port or self.DEFAULT_SARTORIUS_PORT,
98
104
  )
99
105
 
106
+ # Initialize camera
107
+ self.camera = CameraController(
108
+ camera_index=camera_index if camera_index is not None else self.DEFAULT_CAMERA_INDEX,
109
+ )
110
+
100
111
  def connect(self):
101
112
  """Connect all controllers."""
102
113
  self.qubot.connect()
103
114
  self.pipette.connect()
115
+ self.camera.connect()
104
116
 
105
117
  def disconnect(self):
106
118
  """Disconnect all controllers."""
107
119
  self.qubot.disconnect()
108
120
  self.pipette.disconnect()
121
+ self.camera.disconnect()
109
122
 
110
123
  def load_labware(self, slot: str, labware_name: str):
111
124
  """Load a labware object into a slot."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: puda-drivers
3
- Version: 0.0.8
3
+ Version: 0.0.9
4
4
  Summary: Hardware drivers for the PUDA platform.
5
5
  Project-URL: Homepage, https://github.com/zhao-bears/puda-drivers
6
6
  Project-URL: Issues, https://github.com/zhao-bears/puda-drivers/issues
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.14
15
15
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
16
  Classifier: Topic :: System :: Hardware
17
17
  Requires-Python: >=3.8
18
+ Requires-Dist: opencv-python>=4.12.0.88
18
19
  Requires-Dist: pyserial~=3.5
19
20
  Description-Content-Type: text/markdown
20
21
 
@@ -4,13 +4,15 @@ puda_drivers/core/__init__.py,sha256=XbCdXsU6NMDsmEAtavAGiSZZPla5d7zc2L7Qx9qKHdY
4
4
  puda_drivers/core/logging.py,sha256=prOeJ3CGEbm37TtMRyAOTQQiMU5_ImZTRXmcUJxkenc,2892
5
5
  puda_drivers/core/position.py,sha256=f4efmDSrKKCtqrR-GUJxVitPG20MiuGSDOWt-9TVISk,12628
6
6
  puda_drivers/core/serialcontroller.py,sha256=38mKas1iJaOkAE0_V4tmqgZz7RxMMEWfGqA0Ma_Dt2A,8604
7
+ puda_drivers/cv/__init__.py,sha256=DYiPwYOLSUsZ9ivWiHoMGGD3MZ3Ygu9qLYja_OiUodU,100
8
+ puda_drivers/cv/camera.py,sha256=Tzxt1h3W5Z8XFanud_z-PhhJ2UYsC4OEhcak9TVu8jM,16490
7
9
  puda_drivers/labware/__init__.py,sha256=RlRxrJn2zyzyxv4c1KGt8Gmxv2cRO8V4zZVnnyL-I00,288
8
10
  puda_drivers/labware/labware.py,sha256=hZhOzSyb1GP_bm1LvQsREx4YSqLCWBTKNWDgDqfFavI,5317
9
11
  puda_drivers/labware/opentrons_96_tiprack_300ul.json,sha256=jmNaworu688GEgFdxMxNRSaEp4ZOg9IumFk8bVHSzYY,19796
10
12
  puda_drivers/labware/polyelectric_8_wellplate_30000ul.json,sha256=NwXMgHBYnIIFasmrthTDTTV7n1M5DYQBDWh-KYsq1gI,3104
11
13
  puda_drivers/labware/trash_bin.json,sha256=X4TDNDzGbCtJSWlYgGYHUzdnVKj62SggGDNf7z5S0OE,581
12
14
  puda_drivers/machines/__init__.py,sha256=zmIk_r2T8nbPA68h3Cko8N6oL7ncoBpmvhNcAqzHmc4,45
13
- puda_drivers/machines/first.py,sha256=McnZ4q_ykv56aWFqphoZFqXqbhDGZAWCk40CPblgPsw,8492
15
+ puda_drivers/machines/first.py,sha256=QcmKiMunJ-Mgp_B39-45xKldCuXinpimlkki3fiUk6U,9019
14
16
  puda_drivers/move/__init__.py,sha256=NKIKckcqgyviPM0EGFcmIoaqkJM4qekR4babfdddRzM,96
15
17
  puda_drivers/move/deck.py,sha256=yq2B4WMqj0hQvHt8HoJskP10u1DUyKwUnjP2c9gJ174,1397
16
18
  puda_drivers/move/gcode.py,sha256=Aw7la4RkPw737hW5sKl6WiPCmmTnsjLvG4mOb-RwVSc,22592
@@ -21,7 +23,7 @@ puda_drivers/transfer/liquid/sartorius/__init__.py,sha256=QGpKz5YUwa8xCdSMXeZ0iR
21
23
  puda_drivers/transfer/liquid/sartorius/api.py,sha256=jxwIJmY2k1K2ts6NC2ZgFTe4MOiH8TGnJeqYOqNa3rE,28250
22
24
  puda_drivers/transfer/liquid/sartorius/constants.py,sha256=mcsjLrVBH-RSodH-pszstwcEL9wwbV0vOgHbGNxZz9w,2770
23
25
  puda_drivers/transfer/liquid/sartorius/sartorius.py,sha256=bW838jYOAfLlbUqtsKRipZ-RLjrNTcZ7riYV6I4w8G8,13728
24
- puda_drivers-0.0.8.dist-info/METADATA,sha256=_dQYo29lMIclzyd3HGZBvmOidliS92UWTQPPO0g79V0,8597
25
- puda_drivers-0.0.8.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
26
- puda_drivers-0.0.8.dist-info/licenses/LICENSE,sha256=7EI8xVBu6h_7_JlVw-yPhhOZlpY9hP8wal7kHtqKT_E,1074
27
- puda_drivers-0.0.8.dist-info/RECORD,,
26
+ puda_drivers-0.0.9.dist-info/METADATA,sha256=MJGTwICCqHlZXICDBLN_QD_wDSnFApH1n172rOSoYxk,8637
27
+ puda_drivers-0.0.9.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
28
+ puda_drivers-0.0.9.dist-info/licenses/LICENSE,sha256=7EI8xVBu6h_7_JlVw-yPhhOZlpY9hP8wal7kHtqKT_E,1074
29
+ puda_drivers-0.0.9.dist-info/RECORD,,