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 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.1"
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
- from typing import List, Optional
3
- from .models import DeviceConnections, GenICamNodeCategory
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
- def __init__(self, base_url: str, timeout: int = 10):
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 URL of the server (e.g. "http://localhost:8080/api/v1")
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
- # Ensure no trailing slash for cleaner URL joining
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 _get(self, endpoint: str, **kwargs):
21
- """Internal helper for GET requests."""
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 = requests.get(url, timeout=self.timeout, **kwargs)
24
- response.raise_for_status()
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
- url = f"{self.base_url}{endpoint}"
30
- response = requests.put(url, timeout=self.timeout, **kwargs)
31
- response.raise_for_status()
32
- return response
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
- response = self._get(f"/devices/{device_id}/stream/frame/jpeg")
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 typing import List, Optional
1
+ from datetime import datetime
2
2
  from enum import Enum
3
- from pydantic import BaseModel, Field
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.2
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.7
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
- Simple client which uses `requests` to interact with the [racs-api server](https://codeberg.org/javerik/RACS-RustAravisCamServe).
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
- # discover devices
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
- if devices.available:
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
- # 3. Read/Write Settings
390
- width = client.get_device_gc_node_value(target_id, "Width")
391
- print(f"Current Width: {width}")
392
-
393
- client.set_device_gc_node_value(target_id, "Width", "1920")
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
- # 4. Grab an image
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
- # 5. Cleanup
401
- client.disconnect_device(target_id)
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,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (82.0.1)
2
+ Generator: setuptools (84.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -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,,