racs-python 0.0.2__py3-none-any.whl → 0.3.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.
- racs_python/__init__.py +39 -1
- racs_python/burst.py +119 -0
- racs_python/camera.py +229 -0
- racs_python/client.py +260 -21
- racs_python/errors.py +78 -0
- racs_python/frame.py +45 -0
- racs_python/live.py +121 -0
- racs_python/models.py +141 -3
- {racs_python-0.0.2.dist-info → racs_python-0.3.0.dist-info}/METADATA +96 -24
- racs_python-0.3.0.dist-info/RECORD +13 -0
- {racs_python-0.0.2.dist-info → racs_python-0.3.0.dist-info}/WHEEL +1 -1
- racs_python-0.0.2.dist-info/RECORD +0 -8
- {racs_python-0.0.2.dist-info → racs_python-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {racs_python-0.0.2.dist-info → racs_python-0.3.0.dist-info}/top_level.txt +0 -0
racs_python/__init__.py
CHANGED
|
@@ -1,3 +1,41 @@
|
|
|
1
|
+
from .burst import BurstJob
|
|
2
|
+
from .camera import Camera
|
|
1
3
|
from .client import RacsClient
|
|
4
|
+
from .errors import (
|
|
5
|
+
BurstJobFailedError,
|
|
6
|
+
CameraBusyError,
|
|
7
|
+
FeatureError,
|
|
8
|
+
ImageFailedError,
|
|
9
|
+
LiveStreamError,
|
|
10
|
+
NotFoundError,
|
|
11
|
+
RacsError,
|
|
12
|
+
RacsHttpError,
|
|
13
|
+
)
|
|
14
|
+
from .frame import Frame
|
|
15
|
+
from .live import LiveStream
|
|
16
|
+
from .models import (
|
|
17
|
+
AutoPacketSize,
|
|
18
|
+
BurstFormat,
|
|
19
|
+
BurstJobCreated,
|
|
20
|
+
BurstJobInfo,
|
|
21
|
+
DeviceConnections,
|
|
22
|
+
DeviceDiscoverInfo,
|
|
23
|
+
FeatureState,
|
|
24
|
+
GenICamAccessMode,
|
|
25
|
+
GenICamEnumInfo,
|
|
26
|
+
GenICamNodeCategory,
|
|
27
|
+
GenICamNodeInfo,
|
|
28
|
+
GenICamNodeType,
|
|
29
|
+
GenICamVisibility,
|
|
30
|
+
ImageEntryFailed,
|
|
31
|
+
ImageEntryOk,
|
|
32
|
+
JobStatus,
|
|
33
|
+
LiveFormat,
|
|
34
|
+
OnFailure,
|
|
35
|
+
PacketSizeInfo,
|
|
36
|
+
PixelDepth,
|
|
37
|
+
StreamErrorInfo,
|
|
38
|
+
StreamInfo,
|
|
39
|
+
)
|
|
2
40
|
|
|
3
|
-
__version__ = "0.0
|
|
41
|
+
__version__ = "0.3.0"
|
racs_python/burst.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import TYPE_CHECKING, Iterator, List, Optional, Union
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
from .errors import BurstJobFailedError, NotFoundError, RacsError
|
|
6
|
+
from .frame import Frame
|
|
7
|
+
from .models import BurstJobInfo, ImageEntry, ImageEntryOk, JobStatus
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .camera import Camera
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BurstJob:
|
|
14
|
+
"""
|
|
15
|
+
A burst job on the server. The frames stay there until the job is deleted, or expire 10 min
|
|
16
|
+
after it finishes.
|
|
17
|
+
|
|
18
|
+
Used as a context manager, the job is deleted on exit::
|
|
19
|
+
|
|
20
|
+
with cam.burst(20, format="raw") as job:
|
|
21
|
+
for frame in job.frames():
|
|
22
|
+
...
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, camera: "Camera", job_id: Union[UUID, str]):
|
|
26
|
+
self.camera = camera
|
|
27
|
+
self.id = job_id if isinstance(job_id, UUID) else UUID(str(job_id))
|
|
28
|
+
# The first status that carried the frame geometry; it is the same for every frame.
|
|
29
|
+
self._geometry: Optional[BurstJobInfo] = None
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def _client(self):
|
|
33
|
+
return self.camera.client
|
|
34
|
+
|
|
35
|
+
def info(self) -> BurstJobInfo:
|
|
36
|
+
"""The job's status and progress."""
|
|
37
|
+
info = self._client.get_grab_job(self.camera.id, self.id)
|
|
38
|
+
if info.width is not None:
|
|
39
|
+
self._geometry = info
|
|
40
|
+
return info
|
|
41
|
+
|
|
42
|
+
def images(self) -> List[ImageEntry]:
|
|
43
|
+
"""Every frame attempted so far, in capture order: ImageEntryOk or ImageEntryFailed."""
|
|
44
|
+
return self._client.list_grab_job_images(self.camera.id, self.id)
|
|
45
|
+
|
|
46
|
+
def image(self, index: int) -> bytes:
|
|
47
|
+
"""The encoded bytes of frame ``index``."""
|
|
48
|
+
return self._client.get_grab_job_image(self.camera.id, self.id, index)
|
|
49
|
+
|
|
50
|
+
def frame(self, index: int) -> Frame:
|
|
51
|
+
"""Frame ``index`` with its geometry, ready for :meth:`Frame.to_numpy` (raw jobs)."""
|
|
52
|
+
data = self.image(index)
|
|
53
|
+
geometry = self._geometry or self.info()
|
|
54
|
+
if geometry.width is None or geometry.height is None:
|
|
55
|
+
raise RacsError(f"burst job {self.id} reports no frame geometry")
|
|
56
|
+
return Frame(
|
|
57
|
+
data=data,
|
|
58
|
+
width=geometry.width,
|
|
59
|
+
height=geometry.height,
|
|
60
|
+
format=geometry.format.value,
|
|
61
|
+
pixel_format=geometry.pixel_format or "Mono8",
|
|
62
|
+
bit_depth=geometry.bit_depth or 8,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def wait(self, timeout: Optional[float] = None, poll_interval: float = 0.1) -> BurstJobInfo:
|
|
66
|
+
"""
|
|
67
|
+
Block until the job is completed or cancelled and return its final status. Raises
|
|
68
|
+
BurstJobFailedError if it failed and TimeoutError after ``timeout`` seconds.
|
|
69
|
+
"""
|
|
70
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
71
|
+
while True:
|
|
72
|
+
info = self.info()
|
|
73
|
+
if info.status.is_terminal:
|
|
74
|
+
if info.status == JobStatus.FAILED:
|
|
75
|
+
raise BurstJobFailedError(info)
|
|
76
|
+
return info
|
|
77
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
78
|
+
raise TimeoutError(f"burst job {self.id} still {info.status.value} after {timeout} s")
|
|
79
|
+
time.sleep(poll_interval)
|
|
80
|
+
|
|
81
|
+
def frames(self, poll_interval: float = 0.1) -> Iterator[Frame]:
|
|
82
|
+
"""
|
|
83
|
+
Yield every captured frame in order, as soon as it is on the server; failed frames (only
|
|
84
|
+
with ``on_failure="skip"``) are left out. Ends when the job is done, and raises
|
|
85
|
+
BurstJobFailedError after the last frame if the job failed.
|
|
86
|
+
"""
|
|
87
|
+
next_index = 0
|
|
88
|
+
while True:
|
|
89
|
+
# Status first: once it is terminal, the listing that follows is complete.
|
|
90
|
+
info = self.info()
|
|
91
|
+
for entry in self.images():
|
|
92
|
+
if entry.index < next_index:
|
|
93
|
+
continue
|
|
94
|
+
next_index = entry.index + 1
|
|
95
|
+
if isinstance(entry, ImageEntryOk):
|
|
96
|
+
yield self.frame(entry.index)
|
|
97
|
+
if info.status.is_terminal:
|
|
98
|
+
if info.status == JobStatus.FAILED:
|
|
99
|
+
raise BurstJobFailedError(info)
|
|
100
|
+
return
|
|
101
|
+
time.sleep(poll_interval)
|
|
102
|
+
|
|
103
|
+
def delete(self) -> None:
|
|
104
|
+
"""Cancel the job if it still runs, and drop it and its frames from the server."""
|
|
105
|
+
self._client.delete_grab_job(self.camera.id, self.id)
|
|
106
|
+
|
|
107
|
+
cancel = delete
|
|
108
|
+
|
|
109
|
+
def __enter__(self) -> "BurstJob":
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
def __exit__(self, *exc) -> None:
|
|
113
|
+
try:
|
|
114
|
+
self.delete()
|
|
115
|
+
except NotFoundError:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
def __repr__(self) -> str:
|
|
119
|
+
return f"BurstJob(device_id={self.camera.id}, id='{self.id}')"
|
racs_python/camera.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Union
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from .burst import BurstJob
|
|
5
|
+
from .errors import FeatureError, RacsError
|
|
6
|
+
from .frame import Frame
|
|
7
|
+
from .models import (
|
|
8
|
+
AutoPacketSize,
|
|
9
|
+
BurstFormat,
|
|
10
|
+
DeviceDiscoverInfo,
|
|
11
|
+
FeatureState,
|
|
12
|
+
GenICamNodeCategory,
|
|
13
|
+
GenICamNodeInfo,
|
|
14
|
+
GenICamNodeType,
|
|
15
|
+
LiveFormat,
|
|
16
|
+
OnFailure,
|
|
17
|
+
PacketSizeInfo,
|
|
18
|
+
PixelDepth,
|
|
19
|
+
StreamErrorInfo,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from .client import RacsClient
|
|
24
|
+
from .live import LiveStream
|
|
25
|
+
|
|
26
|
+
FeatureValue = Union[int, float, bool, str, None]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _names(names: tuple) -> List[str]:
|
|
30
|
+
"""Accept both ``states("A", "B")`` and ``states(["A", "B"])``."""
|
|
31
|
+
if len(names) == 1 and not isinstance(names[0], str):
|
|
32
|
+
return list(names[0])
|
|
33
|
+
return list(names)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def feature_value(state: FeatureState) -> FeatureValue:
|
|
37
|
+
"""
|
|
38
|
+
A feature state's value as a Python value: int for Integer and Enum (enums are read as their
|
|
39
|
+
integer value), float, bool or str. None for a command or an unavailable feature.
|
|
40
|
+
"""
|
|
41
|
+
if state.error:
|
|
42
|
+
raise FeatureError(state.name, state.error)
|
|
43
|
+
if state.value is None:
|
|
44
|
+
return None
|
|
45
|
+
if state.node_type in (GenICamNodeType.INTEGER, GenICamNodeType.ENUM):
|
|
46
|
+
return int(state.value)
|
|
47
|
+
if state.node_type == GenICamNodeType.FLOAT:
|
|
48
|
+
return float(state.value)
|
|
49
|
+
if state.node_type == GenICamNodeType.BOOLEAN:
|
|
50
|
+
return state.value.lower() == "true"
|
|
51
|
+
return state.value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def format_value(value: Any) -> str:
|
|
55
|
+
"""A Python value in the form the server parses: ``true``/``false`` for bools, else ``str()``."""
|
|
56
|
+
if isinstance(value, bool):
|
|
57
|
+
return "true" if value else "false"
|
|
58
|
+
return str(value)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Camera:
|
|
62
|
+
"""
|
|
63
|
+
One device on a RACS server. Every call goes to the server; the handle only holds the id.
|
|
64
|
+
|
|
65
|
+
Get one from :meth:`RacsClient.camera`, :meth:`RacsClient.connect` or :meth:`RacsClient.cameras`::
|
|
66
|
+
|
|
67
|
+
cam = client.connect(0)
|
|
68
|
+
cam["ExposureTime"] = 5000
|
|
69
|
+
width = cam["Width"]
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, client: "RacsClient", device_id: int):
|
|
73
|
+
self.client = client
|
|
74
|
+
self.id = device_id
|
|
75
|
+
self._categories: Optional[List[GenICamNodeCategory]] = None
|
|
76
|
+
|
|
77
|
+
def __repr__(self) -> str:
|
|
78
|
+
return f"Camera(id={self.id}, server={self.client.base_url!r})"
|
|
79
|
+
|
|
80
|
+
def __eq__(self, other: object) -> bool:
|
|
81
|
+
return isinstance(other, Camera) and other.client is self.client and other.id == self.id
|
|
82
|
+
|
|
83
|
+
def __hash__(self) -> int:
|
|
84
|
+
return hash((id(self.client), self.id))
|
|
85
|
+
|
|
86
|
+
# --- CONNECTION ---
|
|
87
|
+
|
|
88
|
+
def info(self) -> DeviceDiscoverInfo:
|
|
89
|
+
"""The device's identity as the server reports it. Raises RacsError if it is not connected."""
|
|
90
|
+
for device in self.client.get_connected_devices():
|
|
91
|
+
if device.id == self.id:
|
|
92
|
+
return device
|
|
93
|
+
raise RacsError(f"device {self.id} is not connected")
|
|
94
|
+
|
|
95
|
+
def is_connected(self) -> bool:
|
|
96
|
+
return any(d.id == self.id for d in self.client.get_connected_devices())
|
|
97
|
+
|
|
98
|
+
def connect(self, ip: Optional[str] = None) -> "Camera":
|
|
99
|
+
"""Connect the device, by discovery or at ``ip``. Does nothing if it is connected already."""
|
|
100
|
+
self.client.connect_device(self.id, ip)
|
|
101
|
+
self._categories = None
|
|
102
|
+
return self
|
|
103
|
+
|
|
104
|
+
def disconnect(self) -> None:
|
|
105
|
+
self.client.disconnect_device(self.id)
|
|
106
|
+
self._categories = None
|
|
107
|
+
|
|
108
|
+
def xml(self) -> str:
|
|
109
|
+
"""The device's GenICam XML."""
|
|
110
|
+
return self.client.get_device_xml(self.id)
|
|
111
|
+
|
|
112
|
+
def categories(self, refresh: bool = False) -> List[GenICamNodeCategory]:
|
|
113
|
+
"""The GenICam categories and their features. Cached: they don't change while connected."""
|
|
114
|
+
if self._categories is None or refresh:
|
|
115
|
+
self._categories = self.client.get_device_gc_categories(self.id)
|
|
116
|
+
return self._categories
|
|
117
|
+
|
|
118
|
+
def feature_info(self, name: str) -> GenICamNodeInfo:
|
|
119
|
+
"""A feature's description from :meth:`categories`: type, access, enum entries, ..."""
|
|
120
|
+
for category in self.categories():
|
|
121
|
+
for node in category.nodes:
|
|
122
|
+
if node.name == name:
|
|
123
|
+
return node
|
|
124
|
+
raise FeatureError(name, "Feature not found")
|
|
125
|
+
|
|
126
|
+
# --- FEATURES ---
|
|
127
|
+
|
|
128
|
+
def state(self, name: str) -> FeatureState:
|
|
129
|
+
"""A feature's live state: value, availability, lock, bounds."""
|
|
130
|
+
return self.states(name)[0]
|
|
131
|
+
|
|
132
|
+
def states(self, *names: Union[str, Iterable[str]]) -> List[FeatureState]:
|
|
133
|
+
"""The live state of several features in one request, in the order asked."""
|
|
134
|
+
return self.client.get_device_gc_feature_states(self.id, _names(names))
|
|
135
|
+
|
|
136
|
+
def get(self, name: str) -> FeatureValue:
|
|
137
|
+
"""
|
|
138
|
+
A feature's value, typed by the feature: int (Integer, Enum), float, bool or str. None
|
|
139
|
+
for a command or a feature that is unavailable right now. Raises FeatureError for an
|
|
140
|
+
unknown feature or a failed read.
|
|
141
|
+
"""
|
|
142
|
+
return feature_value(self.state(name))
|
|
143
|
+
|
|
144
|
+
def get_many(self, *names: Union[str, Iterable[str]]) -> Dict[str, FeatureValue]:
|
|
145
|
+
"""Several typed values in one request, as ``{name: value}``."""
|
|
146
|
+
return {s.name: feature_value(s) for s in self.states(*names)}
|
|
147
|
+
|
|
148
|
+
def set(self, name: str, value: Any) -> None:
|
|
149
|
+
"""
|
|
150
|
+
Write a feature: an int or float for numbers, a bool for Boolean, an int or the entry
|
|
151
|
+
name for an Enum, a str for String.
|
|
152
|
+
"""
|
|
153
|
+
self.client.set_device_gc_node_value(self.id, name, format_value(value))
|
|
154
|
+
|
|
155
|
+
def execute(self, command: str) -> None:
|
|
156
|
+
"""Execute a command feature such as ``TriggerSoftware`` or ``UserSetLoad``."""
|
|
157
|
+
self.client.execute_device_gc_command(self.id, command)
|
|
158
|
+
|
|
159
|
+
def __getitem__(self, name: str) -> FeatureValue:
|
|
160
|
+
return self.get(name)
|
|
161
|
+
|
|
162
|
+
def __setitem__(self, name: str, value: Any) -> None:
|
|
163
|
+
self.set(name, value)
|
|
164
|
+
|
|
165
|
+
# --- FRAMES ---
|
|
166
|
+
|
|
167
|
+
def grab_jpeg(self, quality: Optional[int] = None) -> bytes:
|
|
168
|
+
"""One frame as a JPEG file (Mono8)."""
|
|
169
|
+
return self.client.grab_jpg_frame(self.id, quality)
|
|
170
|
+
|
|
171
|
+
def grab_raw(self) -> Frame:
|
|
172
|
+
"""One raw Mono8 frame with its size."""
|
|
173
|
+
data = self.client.grab_raw_frame(self.id)
|
|
174
|
+
size = self.get_many("Width", "Height")
|
|
175
|
+
width, height = int(size["Width"]), int(size["Height"])
|
|
176
|
+
if len(data) != width * height:
|
|
177
|
+
raise RacsError(f"raw frame of {len(data)} bytes does not match {width}x{height}; was the size changed?")
|
|
178
|
+
return Frame(data=data, width=width, height=height)
|
|
179
|
+
|
|
180
|
+
def stream_error(self) -> StreamErrorInfo:
|
|
181
|
+
"""Why the last live stream ended, and whether one runs now."""
|
|
182
|
+
return self.client.get_stream_error(self.id)
|
|
183
|
+
|
|
184
|
+
def packet_size(self) -> PacketSizeInfo:
|
|
185
|
+
"""
|
|
186
|
+
The stream packet size, the sizes it may take and the link MTU. Every grab, stream and
|
|
187
|
+
burst uses it as it is; change it with ``cam["GevSCPSPacketSize"] = 7960`` or
|
|
188
|
+
:meth:`auto_packet_size`.
|
|
189
|
+
"""
|
|
190
|
+
return self.client.get_packet_size(self.id)
|
|
191
|
+
|
|
192
|
+
def auto_packet_size(self) -> AutoPacketSize:
|
|
193
|
+
"""Find the largest packet size that reaches the server, with test packets, and set it."""
|
|
194
|
+
return self.client.auto_packet_size(self.id)
|
|
195
|
+
|
|
196
|
+
def live(
|
|
197
|
+
self,
|
|
198
|
+
format: Union[LiveFormat, str] = LiveFormat.JPEG,
|
|
199
|
+
pixel_depth: Union[PixelDepth, str, None] = None,
|
|
200
|
+
quality: Optional[int] = None,
|
|
201
|
+
fps: Optional[float] = None,
|
|
202
|
+
timeout: Optional[float] = None,
|
|
203
|
+
) -> "LiveStream":
|
|
204
|
+
"""
|
|
205
|
+
Start a live stream; iterate it for frames and close it when done::
|
|
206
|
+
|
|
207
|
+
with cam.live(format="raw", pixel_depth="mono16", fps=10) as stream:
|
|
208
|
+
for frame in stream:
|
|
209
|
+
pixels = frame.to_numpy()
|
|
210
|
+
"""
|
|
211
|
+
return self.client.stream_live(self.id, format, pixel_depth, quality, fps, timeout)
|
|
212
|
+
|
|
213
|
+
def burst(
|
|
214
|
+
self,
|
|
215
|
+
count: int,
|
|
216
|
+
format: Union[BurstFormat, str] = BurstFormat.RAW,
|
|
217
|
+
pixel_depth: Union[PixelDepth, str, None] = None,
|
|
218
|
+
quality: Optional[int] = None,
|
|
219
|
+
on_failure: Union[OnFailure, str, None] = None,
|
|
220
|
+
max_failures: Optional[int] = None,
|
|
221
|
+
) -> BurstJob:
|
|
222
|
+
"""Start a background burst of ``count`` (1-500) frames on the server."""
|
|
223
|
+
created = self.client.start_grab_job(
|
|
224
|
+
self.id, count, format, pixel_depth, quality, on_failure, max_failures)
|
|
225
|
+
return BurstJob(self, created.job_id)
|
|
226
|
+
|
|
227
|
+
def job(self, job_id: Union[UUID, str]) -> BurstJob:
|
|
228
|
+
"""A handle for an existing burst job of this device."""
|
|
229
|
+
return BurstJob(self, job_id)
|
racs_python/client.py
CHANGED
|
@@ -1,35 +1,132 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import Iterable, List, Optional, Union
|
|
3
|
+
from urllib.parse import quote, urlsplit, urlunsplit
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
1
6
|
import requests
|
|
2
|
-
|
|
3
|
-
from .
|
|
7
|
+
|
|
8
|
+
from .camera import Camera
|
|
9
|
+
from .errors import http_error
|
|
10
|
+
from .live import LiveStream
|
|
11
|
+
from .models import (
|
|
12
|
+
AutoPacketSize,
|
|
13
|
+
BurstFormat,
|
|
14
|
+
BurstJobCreated,
|
|
15
|
+
BurstJobInfo,
|
|
16
|
+
DeviceConnections,
|
|
17
|
+
DeviceDiscoverInfo,
|
|
18
|
+
FeatureState,
|
|
19
|
+
GenICamNodeCategory,
|
|
20
|
+
ImageEntry,
|
|
21
|
+
LiveFormat,
|
|
22
|
+
OnFailure,
|
|
23
|
+
PacketSizeInfo,
|
|
24
|
+
PixelDepth,
|
|
25
|
+
StreamErrorInfo,
|
|
26
|
+
image_entries,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
API_PATH = "/api/v1"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def normalize_base_url(base_url: str) -> str:
|
|
33
|
+
"""Strip a trailing slash, and add ``/api/v1`` to a bare server URL such as ``http://host:8742``."""
|
|
34
|
+
url = base_url.rstrip("/")
|
|
35
|
+
if not urlsplit(url).path:
|
|
36
|
+
url += API_PATH
|
|
37
|
+
return url
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def websocket_url(base_url: str) -> str:
|
|
41
|
+
"""The ``ws``/``wss`` counterpart of an ``http``/``https`` base URL."""
|
|
42
|
+
parts = urlsplit(base_url)
|
|
43
|
+
scheme = {"http": "ws", "https": "wss"}.get(parts.scheme, parts.scheme)
|
|
44
|
+
return urlunsplit((scheme, parts.netloc, parts.path, "", ""))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _segment(name: str) -> str:
|
|
48
|
+
"""A feature or command name as one URL path segment."""
|
|
49
|
+
return quote(name, safe="")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _wire(value: Union[str, Enum]) -> str:
|
|
53
|
+
return value.value if isinstance(value, Enum) else str(value)
|
|
4
54
|
|
|
5
55
|
|
|
6
56
|
class RacsClient:
|
|
7
|
-
|
|
57
|
+
"""
|
|
58
|
+
HTTP client for a RACS server.
|
|
59
|
+
|
|
60
|
+
The methods here map one to one onto the REST routes and take the device id each time. For
|
|
61
|
+
day-to-day use, get a :class:`Camera` from :meth:`camera`, :meth:`connect` or :meth:`cameras`
|
|
62
|
+
instead: it holds the id and adds typed feature access, live streaming and burst jobs.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, base_url: str, timeout: float = 10):
|
|
8
66
|
"""
|
|
9
67
|
Initialize the client.
|
|
10
68
|
|
|
11
|
-
:param base_url: The root
|
|
69
|
+
:param base_url: The API root (e.g. "http://localhost:8742/api/v1"). A bare server URL
|
|
70
|
+
such as "http://localhost:8742" gets "/api/v1" added.
|
|
12
71
|
:param timeout: Request timeout in seconds.
|
|
13
72
|
"""
|
|
14
|
-
|
|
15
|
-
self.base_url = base_url.rstrip('/')
|
|
73
|
+
self.base_url = normalize_base_url(base_url)
|
|
16
74
|
self.timeout = timeout
|
|
75
|
+
self._session = requests.Session()
|
|
76
|
+
|
|
77
|
+
def close(self) -> None:
|
|
78
|
+
"""Close the underlying HTTP session."""
|
|
79
|
+
self._session.close()
|
|
80
|
+
|
|
81
|
+
def __enter__(self) -> "RacsClient":
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def __exit__(self, *exc) -> None:
|
|
85
|
+
self.close()
|
|
86
|
+
|
|
87
|
+
def __repr__(self) -> str:
|
|
88
|
+
return f"RacsClient({self.base_url!r})"
|
|
17
89
|
|
|
18
90
|
# --- HELPER METHODS ---
|
|
19
91
|
|
|
20
|
-
def
|
|
21
|
-
"""
|
|
92
|
+
def _request(self, method: str, endpoint: str, **kwargs) -> requests.Response:
|
|
93
|
+
"""Send a request; an error status raises the matching :class:`RacsHttpError`."""
|
|
22
94
|
url = f"{self.base_url}{endpoint}"
|
|
23
|
-
response =
|
|
24
|
-
response.
|
|
95
|
+
response = self._session.request(method, url, timeout=self.timeout, **kwargs)
|
|
96
|
+
if not response.ok:
|
|
97
|
+
raise http_error(response.status_code, response.text, url, response)
|
|
25
98
|
return response
|
|
26
99
|
|
|
100
|
+
def _get(self, endpoint: str, **kwargs):
|
|
101
|
+
"""Internal helper for GET requests."""
|
|
102
|
+
return self._request("GET", endpoint, **kwargs)
|
|
103
|
+
|
|
27
104
|
def _put(self, endpoint: str, **kwargs):
|
|
28
105
|
"""Internal helper for PUT requests."""
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
106
|
+
return self._request("PUT", endpoint, **kwargs)
|
|
107
|
+
|
|
108
|
+
def _post(self, endpoint: str, **kwargs):
|
|
109
|
+
"""Internal helper for POST requests."""
|
|
110
|
+
return self._request("POST", endpoint, **kwargs)
|
|
111
|
+
|
|
112
|
+
def _delete(self, endpoint: str, **kwargs):
|
|
113
|
+
"""Internal helper for DELETE requests."""
|
|
114
|
+
return self._request("DELETE", endpoint, **kwargs)
|
|
115
|
+
|
|
116
|
+
# --- CAMERA HANDLES ---
|
|
117
|
+
|
|
118
|
+
def camera(self, device_id: int) -> Camera:
|
|
119
|
+
"""A handle for device ``device_id``. Makes no request; the device need not be connected yet."""
|
|
120
|
+
return Camera(self, device_id)
|
|
121
|
+
|
|
122
|
+
def connect(self, device_id: int, ip: Optional[str] = None) -> Camera:
|
|
123
|
+
"""Connect device ``device_id`` (by discovery, or at ``ip``) and return its handle."""
|
|
124
|
+
self.connect_device(device_id, ip)
|
|
125
|
+
return self.camera(device_id)
|
|
126
|
+
|
|
127
|
+
def cameras(self) -> List[Camera]:
|
|
128
|
+
"""Handles for every device the server is connected to."""
|
|
129
|
+
return [self.camera(d.id) for d in self.get_connected_devices()]
|
|
33
130
|
|
|
34
131
|
# --- DEVICE OPERATIONS ---
|
|
35
132
|
|
|
@@ -41,6 +138,14 @@ class RacsClient:
|
|
|
41
138
|
response = self._get("/devices")
|
|
42
139
|
return DeviceConnections.model_validate(response.json())
|
|
43
140
|
|
|
141
|
+
def get_connected_devices(self) -> List[DeviceDiscoverInfo]:
|
|
142
|
+
"""
|
|
143
|
+
Lists the devices the server is connected to, without running a network discovery.
|
|
144
|
+
GET /devices/connected
|
|
145
|
+
"""
|
|
146
|
+
response = self._get("/devices/connected")
|
|
147
|
+
return [DeviceDiscoverInfo.model_validate(d) for d in response.json()]
|
|
148
|
+
|
|
44
149
|
def connect_device(self, device_id: int, ip: Optional[str] = None) -> None:
|
|
45
150
|
"""
|
|
46
151
|
Connect to a specific device.
|
|
@@ -87,7 +192,7 @@ class RacsClient:
|
|
|
87
192
|
Get a specific GenICam feature value.
|
|
88
193
|
GET /devices/{id}/gc/feature/{feature_name}
|
|
89
194
|
"""
|
|
90
|
-
response = self._get(f"/devices/{device_id}/gc/feature/{feature_name}")
|
|
195
|
+
response = self._get(f"/devices/{device_id}/gc/feature/{_segment(feature_name)}")
|
|
91
196
|
return response.text
|
|
92
197
|
|
|
93
198
|
def set_device_gc_node_value(self, device_id: int, feature_name: str, value: str) -> None:
|
|
@@ -97,27 +202,161 @@ class RacsClient:
|
|
|
97
202
|
"""
|
|
98
203
|
# 'value' is passed as a query parameter per the spec
|
|
99
204
|
self._put(
|
|
100
|
-
f"/devices/{device_id}/gc/feature/{feature_name}",
|
|
205
|
+
f"/devices/{device_id}/gc/feature/{_segment(feature_name)}",
|
|
101
206
|
params={"value": value}
|
|
102
207
|
)
|
|
103
208
|
|
|
209
|
+
def get_device_gc_feature_states(self, device_id: int, names: Iterable[str]) -> List[FeatureState]:
|
|
210
|
+
"""
|
|
211
|
+
Get the live state of 1 to 256 features in one request, in the order asked. A problem
|
|
212
|
+
with one feature is reported in that entry's ``error``.
|
|
213
|
+
POST /devices/{id}/gc/features/state
|
|
214
|
+
"""
|
|
215
|
+
response = self._post(f"/devices/{device_id}/gc/features/state", json={"names": list(names)})
|
|
216
|
+
return [FeatureState.model_validate(item) for item in response.json()]
|
|
217
|
+
|
|
218
|
+
def execute_device_gc_command(self, device_id: int, command_name: str) -> None:
|
|
219
|
+
"""
|
|
220
|
+
Execute a GenICam command feature such as TriggerSoftware.
|
|
221
|
+
POST /devices/{id}/gc/command/{command_name}
|
|
222
|
+
"""
|
|
223
|
+
self._post(f"/devices/{device_id}/gc/command/{_segment(command_name)}")
|
|
224
|
+
|
|
104
225
|
# --- STREAMING OPERATIONS ---
|
|
105
226
|
|
|
106
|
-
def grab_jpg_frame(self, device_id: int) -> bytes:
|
|
227
|
+
def grab_jpg_frame(self, device_id: int, quality: Optional[int] = None) -> bytes:
|
|
107
228
|
"""
|
|
108
|
-
Grab a single frame as JPEG.
|
|
229
|
+
Grab a single frame as JPEG, at ``quality`` 1-100 (the server's default is 80).
|
|
109
230
|
|
|
110
231
|
PixelFormat is automatically set to MONO_8
|
|
111
232
|
GET /devices/{id}/stream/frame/jpeg
|
|
112
233
|
"""
|
|
113
|
-
|
|
234
|
+
params = {"quality": quality} if quality is not None else None
|
|
235
|
+
response = self._get(f"/devices/{device_id}/stream/frame/jpeg", params=params)
|
|
114
236
|
# Spec defines content as octet-stream/array of bytes
|
|
115
237
|
return response.content
|
|
116
238
|
|
|
117
239
|
def grab_raw_frame(self, device_id: int) -> bytes:
|
|
118
240
|
"""
|
|
119
|
-
Grab a single frame as RAW data.
|
|
241
|
+
Grab a single frame as RAW data: Width*Height bytes (Mono8).
|
|
120
242
|
GET /devices/{id}/stream/frame/raw
|
|
121
243
|
"""
|
|
122
244
|
response = self._get(f"/devices/{device_id}/stream/frame/raw")
|
|
123
|
-
return response.content
|
|
245
|
+
return response.content
|
|
246
|
+
|
|
247
|
+
def get_stream_error(self, device_id: int) -> StreamErrorInfo:
|
|
248
|
+
"""
|
|
249
|
+
Why the last live stream on the device ended, and whether one runs now.
|
|
250
|
+
GET /devices/{id}/stream/error
|
|
251
|
+
"""
|
|
252
|
+
response = self._get(f"/devices/{device_id}/stream/error")
|
|
253
|
+
return StreamErrorInfo.model_validate(response.json())
|
|
254
|
+
|
|
255
|
+
def get_packet_size(self, device_id: int) -> PacketSizeInfo:
|
|
256
|
+
"""
|
|
257
|
+
The stream packet size (GevSCPSPacketSize), its bounds and the link MTU. Set it with
|
|
258
|
+
``set_device_gc_node_value(id, "GevSCPSPacketSize", ...)``.
|
|
259
|
+
GET /devices/{id}/stream/packet-size
|
|
260
|
+
"""
|
|
261
|
+
response = self._get(f"/devices/{device_id}/stream/packet-size")
|
|
262
|
+
return PacketSizeInfo.model_validate(response.json())
|
|
263
|
+
|
|
264
|
+
def auto_packet_size(self, device_id: int) -> AutoPacketSize:
|
|
265
|
+
"""
|
|
266
|
+
Find the largest packet size that reaches the server, with test packets, and set it.
|
|
267
|
+
Works during a live stream too, which carries on with the new size.
|
|
268
|
+
POST /devices/{id}/stream/packet-size/auto
|
|
269
|
+
"""
|
|
270
|
+
response = self._post(f"/devices/{device_id}/stream/packet-size/auto")
|
|
271
|
+
return AutoPacketSize.model_validate(response.json())
|
|
272
|
+
|
|
273
|
+
def stream_live(
|
|
274
|
+
self,
|
|
275
|
+
device_id: int,
|
|
276
|
+
format: Union[LiveFormat, str] = LiveFormat.JPEG,
|
|
277
|
+
pixel_depth: Union[PixelDepth, str, None] = None,
|
|
278
|
+
quality: Optional[int] = None,
|
|
279
|
+
fps: Optional[float] = None,
|
|
280
|
+
timeout: Optional[float] = None,
|
|
281
|
+
) -> LiveStream:
|
|
282
|
+
"""
|
|
283
|
+
Open the live-stream WebSocket. The camera is held until the stream is closed.
|
|
284
|
+
|
|
285
|
+
:param timeout: Seconds to wait for each frame; defaults to the client timeout.
|
|
286
|
+
GET /devices/{id}/stream/live (WebSocket)
|
|
287
|
+
"""
|
|
288
|
+
params = {"format": _wire(format)}
|
|
289
|
+
if pixel_depth is not None:
|
|
290
|
+
params["pixel_depth"] = _wire(pixel_depth)
|
|
291
|
+
if quality is not None:
|
|
292
|
+
params["quality"] = str(quality)
|
|
293
|
+
if fps is not None:
|
|
294
|
+
params["fps"] = str(fps)
|
|
295
|
+
return LiveStream(
|
|
296
|
+
self,
|
|
297
|
+
device_id,
|
|
298
|
+
f"{websocket_url(self.base_url)}/devices/{device_id}/stream/live",
|
|
299
|
+
params,
|
|
300
|
+
self.timeout if timeout is None else timeout,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
# --- BURST JOBS ---
|
|
304
|
+
|
|
305
|
+
def start_grab_job(
|
|
306
|
+
self,
|
|
307
|
+
device_id: int,
|
|
308
|
+
count: int,
|
|
309
|
+
format: Union[BurstFormat, str] = BurstFormat.RAW,
|
|
310
|
+
pixel_depth: Union[PixelDepth, str, None] = None,
|
|
311
|
+
quality: Optional[int] = None,
|
|
312
|
+
on_failure: Union[OnFailure, str, None] = None,
|
|
313
|
+
max_failures: Optional[int] = None,
|
|
314
|
+
) -> BurstJobCreated:
|
|
315
|
+
"""
|
|
316
|
+
Start a background burst of ``count`` (1-500) frames. Returns before the capture starts:
|
|
317
|
+
a busy camera shows up as a job that fails.
|
|
318
|
+
POST /devices/{id}/grab/jobs
|
|
319
|
+
"""
|
|
320
|
+
body = {"count": count, "format": _wire(format)}
|
|
321
|
+
if pixel_depth is not None:
|
|
322
|
+
body["pixel_depth"] = _wire(pixel_depth)
|
|
323
|
+
if quality is not None:
|
|
324
|
+
body["quality"] = quality
|
|
325
|
+
if on_failure is not None:
|
|
326
|
+
body["on_failure"] = _wire(on_failure)
|
|
327
|
+
if max_failures is not None:
|
|
328
|
+
body["max_failures"] = max_failures
|
|
329
|
+
response = self._post(f"/devices/{device_id}/grab/jobs", json=body)
|
|
330
|
+
return BurstJobCreated.model_validate(response.json())
|
|
331
|
+
|
|
332
|
+
def get_grab_job(self, device_id: int, job_id: Union[UUID, str]) -> BurstJobInfo:
|
|
333
|
+
"""
|
|
334
|
+
A burst job's progress.
|
|
335
|
+
GET /devices/{id}/grab/jobs/{job_id}
|
|
336
|
+
"""
|
|
337
|
+
response = self._get(f"/devices/{device_id}/grab/jobs/{job_id}")
|
|
338
|
+
return BurstJobInfo.model_validate(response.json())
|
|
339
|
+
|
|
340
|
+
def list_grab_job_images(self, device_id: int, job_id: Union[UUID, str]) -> List[ImageEntry]:
|
|
341
|
+
"""
|
|
342
|
+
The frames a burst job has attempted so far, in capture order.
|
|
343
|
+
GET /devices/{id}/grab/jobs/{job_id}/images
|
|
344
|
+
"""
|
|
345
|
+
response = self._get(f"/devices/{device_id}/grab/jobs/{job_id}/images")
|
|
346
|
+
return image_entries.validate_python(response.json())
|
|
347
|
+
|
|
348
|
+
def get_grab_job_image(self, device_id: int, job_id: Union[UUID, str], index: int) -> bytes:
|
|
349
|
+
"""
|
|
350
|
+
One captured frame, in the job's format. Raises NotFoundError for an index not captured
|
|
351
|
+
(yet) and ImageFailedError for one that failed.
|
|
352
|
+
GET /devices/{id}/grab/jobs/{job_id}/images/{index}
|
|
353
|
+
"""
|
|
354
|
+
response = self._get(f"/devices/{device_id}/grab/jobs/{job_id}/images/{index}")
|
|
355
|
+
return response.content
|
|
356
|
+
|
|
357
|
+
def delete_grab_job(self, device_id: int, job_id: Union[UUID, str]) -> None:
|
|
358
|
+
"""
|
|
359
|
+
Cancel a running burst job or discard a finished one, with its images.
|
|
360
|
+
DELETE /devices/{id}/grab/jobs/{job_id}
|
|
361
|
+
"""
|
|
362
|
+
self._delete(f"/devices/{device_id}/grab/jobs/{job_id}")
|
racs_python/errors.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING, Optional
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from .models import BurstJobInfo
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RacsError(Exception):
|
|
10
|
+
"""Base class of every error raised by this library."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RacsHttpError(RacsError, requests.HTTPError):
|
|
14
|
+
"""
|
|
15
|
+
The server answered with an error status.
|
|
16
|
+
|
|
17
|
+
Subclasses ``requests.HTTPError``, so code written against earlier versions of this library
|
|
18
|
+
(which raised that directly) keeps working.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, status_code: int, body: str, url: str, response: Optional[requests.Response] = None):
|
|
22
|
+
super().__init__(f"{status_code} for {url}: {body}", response=response)
|
|
23
|
+
self.status_code = status_code
|
|
24
|
+
self.body = body
|
|
25
|
+
self.url = url
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CameraBusyError(RacsHttpError):
|
|
29
|
+
"""The camera is held by another request: a live stream, a burst job or a frame grab."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class NotFoundError(RacsHttpError):
|
|
33
|
+
"""404: no such burst job, or a burst image that has not been captured (yet)."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ImageFailedError(RacsHttpError):
|
|
37
|
+
"""410: that burst image was attempted and failed. ``body`` holds the reason."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class FeatureError(RacsError):
|
|
41
|
+
"""A GenICam feature could not be read, e.g. ``Feature not found``."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, name: str, error: str):
|
|
44
|
+
super().__init__(f"{name}: {error}")
|
|
45
|
+
self.name = name
|
|
46
|
+
self.error = error
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class BurstJobFailedError(RacsError):
|
|
50
|
+
"""A burst job ended in ``failed``. ``info.error`` holds the reason."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, info: "BurstJobInfo"):
|
|
53
|
+
super().__init__(f"burst job {info.job_id} failed: {info.error}")
|
|
54
|
+
self.info = info
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class LiveStreamError(RacsError):
|
|
58
|
+
"""The server closed the live stream because of an error."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, reason: str, code: Optional[int] = None):
|
|
61
|
+
super().__init__(reason)
|
|
62
|
+
self.reason = reason
|
|
63
|
+
self.code = code
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def http_error(status_code: int, body: str, url: str,
|
|
67
|
+
response: Optional[requests.Response] = None) -> RacsHttpError:
|
|
68
|
+
"""The exception for an error response, picked by status and body."""
|
|
69
|
+
if status_code == 404:
|
|
70
|
+
cls = NotFoundError
|
|
71
|
+
elif status_code == 410:
|
|
72
|
+
cls = ImageFailedError
|
|
73
|
+
# The server reports a held camera as a 500 whose body names the `Busy` store error.
|
|
74
|
+
elif status_code == 500 and "Busy" in body:
|
|
75
|
+
cls = CameraBusyError
|
|
76
|
+
else:
|
|
77
|
+
cls = RacsHttpError
|
|
78
|
+
return cls(status_code, body, url, response)
|
racs_python/frame.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class Frame:
|
|
7
|
+
"""
|
|
8
|
+
One image, with what it takes to decode it.
|
|
9
|
+
|
|
10
|
+
For ``format == "raw"``, ``data`` is ``width * height`` pixels, row by row: one byte each at a
|
|
11
|
+
``bit_depth`` of 8, otherwise a little-endian u16 each in the range ``0..2**bit_depth``. For
|
|
12
|
+
``"jpeg"`` it is a JPEG file holding the top 8 significant bits.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
data: bytes = field(repr=False)
|
|
16
|
+
width: int
|
|
17
|
+
height: int
|
|
18
|
+
format: str = "raw"
|
|
19
|
+
pixel_format: str = "Mono8"
|
|
20
|
+
bit_depth: int = 8
|
|
21
|
+
# Live stream only: the message number in the session, and the cumulative dropped count.
|
|
22
|
+
frame_number: Optional[int] = None
|
|
23
|
+
dropped: Optional[int] = None
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def bytes_per_pixel(self) -> int:
|
|
27
|
+
return 2 if self.bit_depth > 8 else 1
|
|
28
|
+
|
|
29
|
+
def to_numpy(self):
|
|
30
|
+
"""
|
|
31
|
+
The raw pixels as a ``(height, width)`` array: ``uint8`` at 8 bit, ``uint16`` above.
|
|
32
|
+
|
|
33
|
+
Needs numpy (``pip install racs-python[numpy]``). JPEG frames are not decoded here; pass
|
|
34
|
+
``frame.data`` to an image library such as OpenCV or Pillow.
|
|
35
|
+
"""
|
|
36
|
+
if self.format != "raw":
|
|
37
|
+
raise ValueError(f"to_numpy() needs a raw frame, this one is {self.format}; decode frame.data instead")
|
|
38
|
+
import numpy as np
|
|
39
|
+
|
|
40
|
+
dtype = np.dtype("<u2") if self.bytes_per_pixel == 2 else np.dtype(np.uint8)
|
|
41
|
+
expected = self.width * self.height * dtype.itemsize
|
|
42
|
+
if len(self.data) != expected:
|
|
43
|
+
raise ValueError(
|
|
44
|
+
f"frame holds {len(self.data)} bytes, {self.width}x{self.height} at {self.bit_depth} bit needs {expected}")
|
|
45
|
+
return np.frombuffer(self.data, dtype=dtype).reshape((self.height, self.width))
|
racs_python/live.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import struct
|
|
2
|
+
from typing import TYPE_CHECKING, Dict, Iterator, Optional, Tuple
|
|
3
|
+
from urllib.parse import urlencode
|
|
4
|
+
|
|
5
|
+
import websocket
|
|
6
|
+
|
|
7
|
+
from .errors import LiveStreamError, http_error
|
|
8
|
+
from .frame import Frame
|
|
9
|
+
from .models import StreamInfo
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .client import RacsClient
|
|
13
|
+
|
|
14
|
+
# `[u32 LE frame_number][u32 LE frames_dropped]` in front of every binary message.
|
|
15
|
+
FRAME_HEADER = struct.Struct("<II")
|
|
16
|
+
|
|
17
|
+
# Close codes that end a stream without an error: the client left (1000), or the camera stopped
|
|
18
|
+
# delivering (1001).
|
|
19
|
+
_NORMAL_CLOSE = (1000, 1001)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_frame_message(data: bytes) -> Tuple[int, int, bytes]:
|
|
23
|
+
"""Split a binary live-stream message into ``(frame_number, dropped, image)``."""
|
|
24
|
+
if len(data) < FRAME_HEADER.size:
|
|
25
|
+
raise LiveStreamError(f"frame message of {len(data)} bytes is shorter than its header")
|
|
26
|
+
frame_number, dropped = FRAME_HEADER.unpack_from(data)
|
|
27
|
+
return frame_number, dropped, bytes(data[FRAME_HEADER.size:])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_close(data: bytes) -> Tuple[Optional[int], str]:
|
|
31
|
+
"""The status code and reason of a WebSocket close frame's payload."""
|
|
32
|
+
if len(data) < 2:
|
|
33
|
+
return None, ""
|
|
34
|
+
return struct.unpack(">H", data[:2])[0], data[2:].decode("utf-8", "replace")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class LiveStream:
|
|
38
|
+
"""
|
|
39
|
+
A running live stream: an iterator of :class:`Frame` that ends when the server closes the
|
|
40
|
+
stream normally.
|
|
41
|
+
|
|
42
|
+
The camera is held while the stream is open, so close it (or use it as a context manager)
|
|
43
|
+
when done. GenICam reads and writes still work meanwhile; frame grabs and burst jobs on the
|
|
44
|
+
device fail as busy.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, client: "RacsClient", device_id: int, url: str, params: Dict[str, str], timeout: float):
|
|
48
|
+
self.device_id = device_id
|
|
49
|
+
# Describes the frames being received. Replaced whenever the geometry changes mid-stream.
|
|
50
|
+
self.info: Optional[StreamInfo] = None
|
|
51
|
+
self.closed = False
|
|
52
|
+
full_url = f"{url}?{urlencode(params)}"
|
|
53
|
+
try:
|
|
54
|
+
self._ws = websocket.create_connection(full_url, timeout=timeout)
|
|
55
|
+
except websocket.WebSocketBadStatusException as e:
|
|
56
|
+
body = (getattr(e, "resp_body", None) or b"").decode("utf-8", "replace")
|
|
57
|
+
if not body:
|
|
58
|
+
# The server records why an upgrade was refused.
|
|
59
|
+
body = client.get_stream_error(device_id).last_error or ""
|
|
60
|
+
raise http_error(e.status_code, body, full_url) from None
|
|
61
|
+
|
|
62
|
+
def read(self) -> Optional[Frame]:
|
|
63
|
+
"""The next frame, or None once the stream has ended. Raises LiveStreamError on a failed stream."""
|
|
64
|
+
while not self.closed:
|
|
65
|
+
try:
|
|
66
|
+
opcode, frame = self._ws.recv_data_frame()
|
|
67
|
+
except websocket.WebSocketTimeoutException as e:
|
|
68
|
+
raise TimeoutError(f"no frame from device {self.device_id} within {self._ws.gettimeout()} s") from e
|
|
69
|
+
except websocket.WebSocketConnectionClosedException as e:
|
|
70
|
+
self.closed = True
|
|
71
|
+
raise LiveStreamError("connection closed without a close frame") from e
|
|
72
|
+
|
|
73
|
+
if opcode == websocket.ABNF.OPCODE_TEXT:
|
|
74
|
+
self.info = StreamInfo.model_validate_json(frame.data)
|
|
75
|
+
elif opcode == websocket.ABNF.OPCODE_BINARY:
|
|
76
|
+
if self.info is None:
|
|
77
|
+
raise LiveStreamError("frame received before the stream info")
|
|
78
|
+
frame_number, dropped, image = parse_frame_message(frame.data)
|
|
79
|
+
return Frame(
|
|
80
|
+
data=image,
|
|
81
|
+
width=self.info.width,
|
|
82
|
+
height=self.info.height,
|
|
83
|
+
format=self.info.format.value,
|
|
84
|
+
pixel_format=self.info.pixel_format,
|
|
85
|
+
bit_depth=self.info.bit_depth,
|
|
86
|
+
frame_number=frame_number,
|
|
87
|
+
dropped=dropped,
|
|
88
|
+
)
|
|
89
|
+
elif opcode == websocket.ABNF.OPCODE_CLOSE:
|
|
90
|
+
self.closed = True
|
|
91
|
+
code, reason = parse_close(frame.data)
|
|
92
|
+
if code is not None and code not in _NORMAL_CLOSE:
|
|
93
|
+
raise LiveStreamError(reason or f"stream closed with code {code}", code)
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
def close(self) -> None:
|
|
97
|
+
"""Stop the stream and release the camera."""
|
|
98
|
+
if not self.closed:
|
|
99
|
+
self.closed = True
|
|
100
|
+
try:
|
|
101
|
+
self._ws.close()
|
|
102
|
+
except websocket.WebSocketException:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
def __iter__(self) -> Iterator[Frame]:
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
def __next__(self) -> Frame:
|
|
109
|
+
frame = self.read()
|
|
110
|
+
if frame is None:
|
|
111
|
+
raise StopIteration
|
|
112
|
+
return frame
|
|
113
|
+
|
|
114
|
+
def __enter__(self) -> "LiveStream":
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
def __exit__(self, *exc) -> None:
|
|
118
|
+
self.close()
|
|
119
|
+
|
|
120
|
+
def __repr__(self) -> str:
|
|
121
|
+
return f"LiveStream(device_id={self.device_id}, info={self.info!r}, closed={self.closed})"
|
racs_python/models.py
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
from
|
|
1
|
+
from datetime import datetime
|
|
2
2
|
from enum import Enum
|
|
3
|
-
from
|
|
3
|
+
from typing import Annotated, List, Literal, Optional, Union
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field, TypeAdapter
|
|
4
7
|
|
|
5
8
|
# --- ENUMS ---
|
|
6
9
|
|
|
@@ -17,6 +20,8 @@ class GenICamNodeType(str, Enum):
|
|
|
17
20
|
FLOAT = "Float"
|
|
18
21
|
STRING = "String"
|
|
19
22
|
ENUM = "Enum"
|
|
23
|
+
BOOLEAN = "Boolean"
|
|
24
|
+
COMMAND = "Command"
|
|
20
25
|
|
|
21
26
|
class GenICamVisibility(str, Enum):
|
|
22
27
|
BEGINNER = "Beginner"
|
|
@@ -25,6 +30,36 @@ class GenICamVisibility(str, Enum):
|
|
|
25
30
|
INVISIBLE = "Invisible"
|
|
26
31
|
UNDEFINED = "Undefined"
|
|
27
32
|
|
|
33
|
+
class PixelDepth(str, Enum):
|
|
34
|
+
"""Requested depth of live and burst frames. The camera's answer is in ``bit_depth``."""
|
|
35
|
+
MONO8 = "mono8"
|
|
36
|
+
# The deepest unpacked mono format the camera accepts: Mono16, 14, 12, then 10
|
|
37
|
+
# (Coord3D_C16 on a 3D camera in Linescan3D).
|
|
38
|
+
MONO16 = "mono16"
|
|
39
|
+
|
|
40
|
+
class LiveFormat(str, Enum):
|
|
41
|
+
JPEG = "jpeg"
|
|
42
|
+
RAW = "raw"
|
|
43
|
+
|
|
44
|
+
class BurstFormat(str, Enum):
|
|
45
|
+
RAW = "raw"
|
|
46
|
+
JPEG = "jpeg"
|
|
47
|
+
|
|
48
|
+
class OnFailure(str, Enum):
|
|
49
|
+
ABORT = "abort"
|
|
50
|
+
SKIP = "skip"
|
|
51
|
+
|
|
52
|
+
class JobStatus(str, Enum):
|
|
53
|
+
PENDING = "pending"
|
|
54
|
+
RUNNING = "running"
|
|
55
|
+
COMPLETED = "completed"
|
|
56
|
+
FAILED = "failed"
|
|
57
|
+
CANCELLED = "cancelled"
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def is_terminal(self) -> bool:
|
|
61
|
+
return self in (JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED)
|
|
62
|
+
|
|
28
63
|
# --- MODELS ---
|
|
29
64
|
|
|
30
65
|
class GenICamEnumInfo(BaseModel):
|
|
@@ -41,6 +76,8 @@ class GenICamNodeInfo(BaseModel):
|
|
|
41
76
|
visibility: GenICamVisibility
|
|
42
77
|
# "enums" can be a list or null. Default to None if missing.
|
|
43
78
|
enums: Optional[List[GenICamEnumInfo]] = None
|
|
79
|
+
# For a selector (e.g. GainSelector): the features whose value depends on it.
|
|
80
|
+
selected: List[str] = Field(default_factory=list)
|
|
44
81
|
|
|
45
82
|
class GenICamNodeCategory(BaseModel):
|
|
46
83
|
name: str
|
|
@@ -59,4 +96,105 @@ class DeviceDiscoverInfo(BaseModel):
|
|
|
59
96
|
|
|
60
97
|
class DeviceConnections(BaseModel):
|
|
61
98
|
available: List[DeviceDiscoverInfo]
|
|
62
|
-
connected: List[DeviceDiscoverInfo]
|
|
99
|
+
connected: List[DeviceDiscoverInfo]
|
|
100
|
+
|
|
101
|
+
class FeatureState(BaseModel):
|
|
102
|
+
"""Live state of a feature, from ``POST /devices/{id}/gc/features/state``."""
|
|
103
|
+
name: str
|
|
104
|
+
# None when the feature isn't known (see `error`).
|
|
105
|
+
node_type: Optional[GenICamNodeType] = None
|
|
106
|
+
# Formatted as by the single-feature GET. None for a command, a write-only or unavailable
|
|
107
|
+
# feature, or a failed read.
|
|
108
|
+
value: Optional[str] = None
|
|
109
|
+
available: bool
|
|
110
|
+
# The feature can be read but not written right now.
|
|
111
|
+
locked: bool
|
|
112
|
+
# int stays exact for 64-bit integer bounds.
|
|
113
|
+
min: Optional[Union[int, float]] = None
|
|
114
|
+
max: Optional[Union[int, float]] = None
|
|
115
|
+
inc: Optional[Union[int, float]] = None
|
|
116
|
+
unit: Optional[str] = None
|
|
117
|
+
representation: Optional[str] = None
|
|
118
|
+
# For an enum: the integer values of the entries the device currently offers.
|
|
119
|
+
available_entries: Optional[List[int]] = None
|
|
120
|
+
error: Optional[str] = None
|
|
121
|
+
|
|
122
|
+
class StreamErrorInfo(BaseModel):
|
|
123
|
+
"""Why the last live stream on a device ended, and whether one runs now."""
|
|
124
|
+
last_error: Optional[str] = None
|
|
125
|
+
timestamp: Optional[datetime] = None
|
|
126
|
+
active_stream: bool
|
|
127
|
+
|
|
128
|
+
class StreamInfo(BaseModel):
|
|
129
|
+
"""Describes every live-stream frame after it, until the next one."""
|
|
130
|
+
width: int
|
|
131
|
+
height: int
|
|
132
|
+
format: LiveFormat
|
|
133
|
+
pixel_format: str
|
|
134
|
+
# Significant bits per pixel. Above 8, raw frames are little-endian u16s.
|
|
135
|
+
bit_depth: int
|
|
136
|
+
# GevSCPSPacketSize the stream runs with. None from servers that don't report it.
|
|
137
|
+
packet_size: Optional[int] = None
|
|
138
|
+
|
|
139
|
+
class PacketSizeInfo(BaseModel):
|
|
140
|
+
"""A camera's stream packet size, what it may be set to, and the link it travels over."""
|
|
141
|
+
# GevSCPSPacketSize: bytes per stream packet, IP and UDP headers included.
|
|
142
|
+
packet_size: int
|
|
143
|
+
# Allowed sizes are the multiples of `inc` from `min` to `max`.
|
|
144
|
+
min: int
|
|
145
|
+
max: int
|
|
146
|
+
inc: int
|
|
147
|
+
# MTU of the local interface the camera is reached over; None when unknown. A larger packet
|
|
148
|
+
# size never arrives.
|
|
149
|
+
link_mtu: Optional[int] = None
|
|
150
|
+
interface: Optional[str] = None
|
|
151
|
+
local_ip: Optional[str] = None
|
|
152
|
+
|
|
153
|
+
class AutoPacketSize(BaseModel):
|
|
154
|
+
"""The outcome of a test-packet search for the largest packet size that arrives."""
|
|
155
|
+
packet_size: int
|
|
156
|
+
previous: int
|
|
157
|
+
# False when the camera answered no test packet; the size is then left as it was.
|
|
158
|
+
test_packets: bool
|
|
159
|
+
link_mtu: Optional[int] = None
|
|
160
|
+
interface: Optional[str] = None
|
|
161
|
+
|
|
162
|
+
class BurstJobCreated(BaseModel):
|
|
163
|
+
job_id: UUID
|
|
164
|
+
device_id: int
|
|
165
|
+
status: JobStatus
|
|
166
|
+
|
|
167
|
+
class BurstJobInfo(BaseModel):
|
|
168
|
+
job_id: UUID
|
|
169
|
+
device_id: int
|
|
170
|
+
status: JobStatus
|
|
171
|
+
format: BurstFormat
|
|
172
|
+
requested_count: int
|
|
173
|
+
captured_count: int
|
|
174
|
+
failed_count: int
|
|
175
|
+
created_at: datetime
|
|
176
|
+
started_at: Optional[datetime] = None
|
|
177
|
+
completed_at: Optional[datetime] = None
|
|
178
|
+
# When the finished job is dropped from the server. None until it finishes.
|
|
179
|
+
expires_at: Optional[datetime] = None
|
|
180
|
+
# Why the job failed. None unless `status` is failed.
|
|
181
|
+
error: Optional[str] = None
|
|
182
|
+
# Frame geometry, None until the first frame is captured.
|
|
183
|
+
width: Optional[int] = None
|
|
184
|
+
height: Optional[int] = None
|
|
185
|
+
pixel_format: Optional[str] = None
|
|
186
|
+
bit_depth: Optional[int] = None
|
|
187
|
+
|
|
188
|
+
class ImageEntryOk(BaseModel):
|
|
189
|
+
status: Literal["ok"] = "ok"
|
|
190
|
+
index: int
|
|
191
|
+
size_bytes: int
|
|
192
|
+
|
|
193
|
+
class ImageEntryFailed(BaseModel):
|
|
194
|
+
status: Literal["failed"] = "failed"
|
|
195
|
+
index: int
|
|
196
|
+
error: str
|
|
197
|
+
|
|
198
|
+
ImageEntry = Annotated[Union[ImageEntryOk, ImageEntryFailed], Field(discriminator="status")]
|
|
199
|
+
|
|
200
|
+
image_entries = TypeAdapter(List[ImageEntry])
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: racs-python
|
|
3
|
-
Version: 0.0
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Client library to access racs api server
|
|
5
5
|
Author-email: Jonas Ahlf <javerik@javerik.space>
|
|
6
6
|
License: GNU GENERAL PUBLIC LICENSE
|
|
@@ -349,55 +349,127 @@ Keywords: racs,aravis,camera,client,api,genicam,gev,gigevision
|
|
|
349
349
|
Classifier: Programming Language :: Python :: 3
|
|
350
350
|
Classifier: License :: OSI Approved :: GNU General Public License v2 (GPLv2)
|
|
351
351
|
Classifier: Operating System :: OS Independent
|
|
352
|
-
Requires-Python: >=3.
|
|
352
|
+
Requires-Python: >=3.9
|
|
353
353
|
Description-Content-Type: text/markdown
|
|
354
354
|
License-File: LICENSE
|
|
355
355
|
Requires-Dist: requests>=2.31
|
|
356
356
|
Requires-Dist: pydantic>=2.0
|
|
357
|
+
Requires-Dist: websocket-client>=1.6
|
|
358
|
+
Provides-Extra: numpy
|
|
359
|
+
Requires-Dist: numpy; extra == "numpy"
|
|
360
|
+
Provides-Extra: test
|
|
361
|
+
Requires-Dist: pytest; extra == "test"
|
|
362
|
+
Requires-Dist: numpy; extra == "test"
|
|
357
363
|
Dynamic: license-file
|
|
358
364
|
|
|
359
365
|
# RACS python client library
|
|
360
366
|
|
|
361
|
-
|
|
367
|
+
Client for the [racs-api server](https://codeberg.org/javerik/RACS-RustAravisCamServe): device
|
|
368
|
+
discovery and connection, typed GenICam feature access, single frames, the live stream
|
|
369
|
+
(WebSocket) and burst jobs.
|
|
362
370
|
|
|
363
371
|
## Install
|
|
364
372
|
|
|
365
373
|
```shell
|
|
366
|
-
pip install racs-python
|
|
374
|
+
pip install racs-python # numpy is optional:
|
|
375
|
+
pip install "racs-python[numpy]" # adds Frame.to_numpy()
|
|
367
376
|
```
|
|
368
377
|
|
|
369
378
|
## Usage
|
|
370
379
|
|
|
371
380
|
> **Make sure an instance of racs-api server is running**
|
|
372
381
|
|
|
382
|
+
A `Camera` holds the device id, so nothing else needs to pass it around:
|
|
383
|
+
|
|
373
384
|
```python
|
|
374
385
|
from racs_python import RacsClient
|
|
375
386
|
|
|
376
|
-
client = RacsClient("http://localhost:8742/api/v1"
|
|
387
|
+
client = RacsClient("http://localhost:8742") # "/api/v1" is added to a bare server URL
|
|
377
388
|
|
|
378
|
-
#
|
|
379
|
-
devices = client.get_devices()
|
|
389
|
+
devices = client.get_devices() # network discovery
|
|
380
390
|
print(f"Found {len(devices.available)} available devices.")
|
|
381
391
|
|
|
382
|
-
|
|
383
|
-
target_id = devices.available[0].id
|
|
384
|
-
|
|
385
|
-
# 2. Connection
|
|
386
|
-
client.connect_device(target_id)
|
|
387
|
-
print(f"Connected to device {target_id}")
|
|
392
|
+
cam = client.connect(devices.available[0].id) # or client.connect(5, ip="192.168.0.10")
|
|
388
393
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
+
# Features are read and written as Python values
|
|
395
|
+
print(cam["Width"]) # 1280 (int)
|
|
396
|
+
cam["ExposureTime"] = 5000.0
|
|
397
|
+
cam["ReverseX"] = True
|
|
398
|
+
width, height = cam.get_many("Width", "Height").values()
|
|
399
|
+
cam.execute("TriggerSoftware")
|
|
400
|
+
|
|
401
|
+
# Single frames
|
|
402
|
+
with open("frame.jpg", "wb") as f:
|
|
403
|
+
f.write(cam.grab_jpeg(quality=90))
|
|
404
|
+
frame = cam.grab_raw() # Mono8, with width/height
|
|
405
|
+
pixels = frame.to_numpy() # (height, width) uint8
|
|
406
|
+
|
|
407
|
+
cam.disconnect()
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
Already connected devices: `client.cameras()`, or `client.camera(id)` for a handle without a request.
|
|
411
|
+
|
|
412
|
+
### Live stream
|
|
413
|
+
|
|
414
|
+
The stream holds the camera until it is closed. Features can still be read and written while it
|
|
415
|
+
runs; a change of size arrives as a new `stream.info` and applies to the frames after it.
|
|
416
|
+
|
|
417
|
+
```python
|
|
418
|
+
from itertools import islice
|
|
419
|
+
|
|
420
|
+
with cam.live(format="raw", pixel_depth="mono16", fps=10) as stream:
|
|
421
|
+
for frame in islice(stream, 100):
|
|
422
|
+
pixels = frame.to_numpy() # uint16 at bit_depth > 8
|
|
423
|
+
print(frame.frame_number, frame.dropped, frame.bit_depth, pixels.mean())
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
`format="jpeg"` (the default) delivers JPEG files in `frame.data`. A stream that the server ends
|
|
427
|
+
because of an error raises `LiveStreamError`; `cam.stream_error()` reports the last one.
|
|
394
428
|
|
|
395
|
-
|
|
396
|
-
jpg_data = client.grab_jpg_frame(target_id)
|
|
397
|
-
with open("frame.jpg", "wb") as f:
|
|
398
|
-
f.write(jpg_data)
|
|
429
|
+
### Packet size
|
|
399
430
|
|
|
400
|
-
|
|
401
|
-
|
|
431
|
+
Every grab, stream and burst uses the camera's stream packet size (`GevSCPSPacketSize`, bytes per
|
|
432
|
+
packet including IP and UDP headers) as it is. Jumbo frames such as 7960 need a link MTU of 9000.
|
|
402
433
|
|
|
434
|
+
```python
|
|
435
|
+
info = cam.packet_size() # packet_size, min/max/inc, link_mtu, interface
|
|
436
|
+
cam["GevSCPSPacketSize"] = 7960 # a multiple of info.inc, not above the link MTU
|
|
437
|
+
auto = cam.auto_packet_size() # the largest size whose test packets arrive
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
Both work during a live stream, which pauses briefly and carries on with the new size
|
|
441
|
+
(`stream.info.packet_size`).
|
|
442
|
+
|
|
443
|
+
### Burst jobs
|
|
444
|
+
|
|
445
|
+
A burst grabs up to 500 frames on the server in the background. Leaving the `with` block deletes
|
|
446
|
+
the job and its frames.
|
|
447
|
+
|
|
448
|
+
```python
|
|
449
|
+
with cam.burst(50, format="raw", pixel_depth="mono16") as job:
|
|
450
|
+
for frame in job.frames(): # yields each frame as soon as it is captured
|
|
451
|
+
...
|
|
452
|
+
info = job.wait() # final status
|
|
403
453
|
```
|
|
454
|
+
|
|
455
|
+
`job.image(i)` returns the encoded bytes of one frame, `job.images()` lists what has been
|
|
456
|
+
captured, and `cam.job(job_id)` re-attaches to a job by id.
|
|
457
|
+
|
|
458
|
+
### Errors
|
|
459
|
+
|
|
460
|
+
Every error is a `RacsError`. HTTP errors are `RacsHttpError` (a `requests.HTTPError`) with
|
|
461
|
+
`status_code` and `body`; the specific ones are `CameraBusyError` (the camera is held by a stream,
|
|
462
|
+
burst or grab), `NotFoundError` and `ImageFailedError`. Feature reads raise `FeatureError`, a
|
|
463
|
+
failed burst `BurstJobFailedError`.
|
|
464
|
+
|
|
465
|
+
### Low-level client
|
|
466
|
+
|
|
467
|
+
`RacsClient` maps one method onto each REST route and takes the device id every time, e.g.
|
|
468
|
+
`client.get_device_gc_node_value(0, "Width")` or `client.start_grab_job(0, 20, "raw")`. Values
|
|
469
|
+
are strings there, exactly as the server sends them.
|
|
470
|
+
|
|
471
|
+
## Tests
|
|
472
|
+
|
|
473
|
+
`client_libs/run-tests.sh python` builds the repository's fake-camera server
|
|
474
|
+
(`cargo run --example fake_server`), starts it and runs the tests against it. `pytest` on its own
|
|
475
|
+
runs only the tests that need no server.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
racs_python/__init__.py,sha256=nwzPFp_Bkdl9UjSba1VNsjlsWDsdys-q-zPwWAUS-04,805
|
|
2
|
+
racs_python/burst.py,sha256=4DzEBH9EpdYjA_BwyvrweYAhe52iL4oPHi7B12n1RBk,4556
|
|
3
|
+
racs_python/camera.py,sha256=VG44sHqSaRXUumqFmEeXLrc7kVy-clNnWBFjofTrqpw,8686
|
|
4
|
+
racs_python/client.py,sha256=1zN51ugwENVRJh5YoTNHxEJzJY7RU6OZdszPVfiwjcE,13678
|
|
5
|
+
racs_python/errors.py,sha256=yiNt_ALUvUyvXjIhVFAHS75oiM9bJ4ya0Hh078tX9ns,2473
|
|
6
|
+
racs_python/frame.py,sha256=NDyvYzKUsgS8S5LhiFuN8DNcLhiBFdyMBEl1YQ9YeyI,1750
|
|
7
|
+
racs_python/live.py,sha256=KSq_XM3QQpE3M1VDQnXOiV1sr_33Xl04siySY673-5s,4905
|
|
8
|
+
racs_python/models.py,sha256=ygGOpYohZzmcu7SsHSfAWQB0WkH-sfKuHgB2S9R4LIs,6100
|
|
9
|
+
racs_python-0.3.0.dist-info/licenses/LICENSE,sha256=gXf5dRMhNSbfLPYYTY_5hsZ1r7UU1OaKQEAQUhuIBkM,18092
|
|
10
|
+
racs_python-0.3.0.dist-info/METADATA,sha256=CyOqQAUNvJIZjt68BeTS-hz11PSQW9ShP-LfPIzPO9U,25899
|
|
11
|
+
racs_python-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
12
|
+
racs_python-0.3.0.dist-info/top_level.txt,sha256=FnooQMJiYyMezNBcuvAIo569MQfg7e1V7t84Tw-evlY,12
|
|
13
|
+
racs_python-0.3.0.dist-info/RECORD,,
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
racs_python/__init__.py,sha256=gxcymsmUiGNEvWz4-y8EVM6qxIvFWswqqvNzJnlzAdM,53
|
|
2
|
-
racs_python/client.py,sha256=1CNZ-wOB_Q0ySskFXu-tp2hx_wIhQLxyb6hnIa2NRrg,4263
|
|
3
|
-
racs_python/models.py,sha256=kUGVMqxY4gu3HQjVyRWh47-aMPn9t-hhezP9hwPGuA0,1465
|
|
4
|
-
racs_python-0.0.2.dist-info/licenses/LICENSE,sha256=gXf5dRMhNSbfLPYYTY_5hsZ1r7UU1OaKQEAQUhuIBkM,18092
|
|
5
|
-
racs_python-0.0.2.dist-info/METADATA,sha256=iJ3uXAuEALogV6lo73CLQAOGom7iasSlcs5hvMAwqlI,22656
|
|
6
|
-
racs_python-0.0.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
7
|
-
racs_python-0.0.2.dist-info/top_level.txt,sha256=FnooQMJiYyMezNBcuvAIo569MQfg7e1V7t84Tw-evlY,12
|
|
8
|
-
racs_python-0.0.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|