streamcraft 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- streamcraft/__init__.py +52 -0
- streamcraft/devices.py +517 -0
- streamcraft/pipeline.py +334 -0
- streamcraft/webrtc.py +523 -0
- streamcraft-0.1.0.dist-info/METADATA +358 -0
- streamcraft-0.1.0.dist-info/RECORD +9 -0
- streamcraft-0.1.0.dist-info/WHEEL +5 -0
- streamcraft-0.1.0.dist-info/licenses/LICENSE +21 -0
- streamcraft-0.1.0.dist-info/top_level.txt +1 -0
streamcraft/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""
|
|
2
|
+
streamcraft — Friendly Python utilities for GStreamer.
|
|
3
|
+
|
|
4
|
+
Sits on top of the standard gi.repository GStreamer bindings,
|
|
5
|
+
reducing boilerplate while keeping all GStreamer concepts visible.
|
|
6
|
+
|
|
7
|
+
Quick start:
|
|
8
|
+
from streamcraft import PipelineBuilder
|
|
9
|
+
|
|
10
|
+
pipeline, elems = (
|
|
11
|
+
PipelineBuilder()
|
|
12
|
+
.element("v4l2src", name="src", device="/dev/video0")
|
|
13
|
+
.caps("image/jpeg,width=1280,height=720,framerate=30/1")
|
|
14
|
+
.element("queue", max_size_buffers=3, leaky=2)
|
|
15
|
+
.element("jpegdec")
|
|
16
|
+
.element("videoconvert")
|
|
17
|
+
.element("x264enc", tune="zerolatency", speed_preset="ultrafast", bitrate=2000)
|
|
18
|
+
.element("h264parse", config_interval=1)
|
|
19
|
+
.element("rtph264pay", pt=96)
|
|
20
|
+
.build()
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# The pipeline is a real Gst.Pipeline — do anything with it
|
|
24
|
+
encoder = elems["encoder"] # or pipeline.get_by_name("encoder")
|
|
25
|
+
pipeline.set_state(Gst.State.PLAYING)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from .pipeline import PipelineBuilder
|
|
29
|
+
from .devices import (
|
|
30
|
+
require_elements,
|
|
31
|
+
check_v4l2_device,
|
|
32
|
+
list_v4l2_devices,
|
|
33
|
+
V4L2PTZCamera,
|
|
34
|
+
PTZStatus,
|
|
35
|
+
ControlRange,
|
|
36
|
+
)
|
|
37
|
+
from .webrtc import WebRTCSession
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
# Pipeline building
|
|
41
|
+
"PipelineBuilder",
|
|
42
|
+
# Device utilities
|
|
43
|
+
"require_elements",
|
|
44
|
+
"check_v4l2_device",
|
|
45
|
+
"list_v4l2_devices",
|
|
46
|
+
"V4L2PTZCamera",
|
|
47
|
+
"PTZStatus",
|
|
48
|
+
"ControlRange",
|
|
49
|
+
# WebRTC session management
|
|
50
|
+
"WebRTCSession",
|
|
51
|
+
]
|
|
52
|
+
__version__ = "0.1.0"
|
streamcraft/devices.py
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
"""
|
|
2
|
+
streamcraft.devices — Device detection and camera control for GStreamer pipelines.
|
|
3
|
+
|
|
4
|
+
This module solves two distinct but related problems that come up early in
|
|
5
|
+
every GStreamer project:
|
|
6
|
+
|
|
7
|
+
1. "Does this machine have what my pipeline needs?" — checked via
|
|
8
|
+
require_elements() and check_v4l2_device() before you try to build.
|
|
9
|
+
|
|
10
|
+
2. "I have a PTZ camera; how do I control it?" — answered by V4L2PTZCamera,
|
|
11
|
+
which auto-detects a camera's capabilities and presents a clean API.
|
|
12
|
+
|
|
13
|
+
The design philosophy is the same as the rest of streamcraft: we sit on top of
|
|
14
|
+
the standard tools (v4l2-ctl, GStreamer's ElementFactory) without hiding them.
|
|
15
|
+
If you need to go deeper, the underlying commands and APIs are still there.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Dict, List, Optional, Tuple
|
|
25
|
+
|
|
26
|
+
import gi
|
|
27
|
+
gi.require_version("Gst", "1.0")
|
|
28
|
+
from gi.repository import Gst
|
|
29
|
+
|
|
30
|
+
Gst.init(None)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
# Part 1: Environment / dependency checking
|
|
35
|
+
#
|
|
36
|
+
# These functions answer the question "can I even start?" before you spend
|
|
37
|
+
# time building a pipeline that will fail in a confusing way at runtime.
|
|
38
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
def require_elements(*factory_names: str) -> None:
|
|
41
|
+
"""
|
|
42
|
+
Assert that all named GStreamer element factories are available on this system.
|
|
43
|
+
|
|
44
|
+
Call this near the top of your program, before building any pipelines.
|
|
45
|
+
If anything is missing, you get a clear error message with the exact
|
|
46
|
+
apt-get command to fix it — rather than a cryptic None or segfault
|
|
47
|
+
later when the missing element is actually needed.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
*factory_names: One or more GStreamer element factory names, e.g.:
|
|
51
|
+
require_elements("webrtcbin", "x264enc", "alsasrc", "rtph264pay")
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
EnvironmentError: If one or more elements are not found, with a
|
|
55
|
+
grouped message listing all missing elements and install hints.
|
|
56
|
+
|
|
57
|
+
Example:
|
|
58
|
+
# Check everything your pipeline needs in one shot at startup
|
|
59
|
+
require_elements(
|
|
60
|
+
"webrtcbin", "v4l2src", "jpegdec", "x264enc",
|
|
61
|
+
"h264parse", "rtph264pay", "alsasrc", "opusenc", "rtpopuspay"
|
|
62
|
+
)
|
|
63
|
+
"""
|
|
64
|
+
missing = [
|
|
65
|
+
name for name in factory_names
|
|
66
|
+
if Gst.ElementFactory.find(name) is None
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
if not missing:
|
|
70
|
+
return # All good — fast path, no output
|
|
71
|
+
|
|
72
|
+
# Build a helpful error message. We group the missing elements by which
|
|
73
|
+
# plugin package typically provides them, so the user knows what to install.
|
|
74
|
+
_PLUGIN_HINTS = {
|
|
75
|
+
"v4l2src": "gstreamer1.0-plugins-good",
|
|
76
|
+
"jpegdec": "gstreamer1.0-plugins-good",
|
|
77
|
+
"rtpvp8pay": "gstreamer1.0-plugins-good",
|
|
78
|
+
"rtpopuspay": "gstreamer1.0-plugins-good",
|
|
79
|
+
"alsasrc": "gstreamer1.0-alsa",
|
|
80
|
+
"alsasink": "gstreamer1.0-alsa",
|
|
81
|
+
"pulsesrc": "gstreamer1.0-pulseaudio",
|
|
82
|
+
"pulsesink": "gstreamer1.0-pulseaudio",
|
|
83
|
+
"audioconvert": "gstreamer1.0-plugins-base",
|
|
84
|
+
"audioresample": "gstreamer1.0-plugins-base",
|
|
85
|
+
"videoconvert": "gstreamer1.0-plugins-base",
|
|
86
|
+
"videoscale": "gstreamer1.0-plugins-base",
|
|
87
|
+
"opusenc": "gstreamer1.0-plugins-base",
|
|
88
|
+
"vorbisenc": "gstreamer1.0-plugins-base",
|
|
89
|
+
"theoraenc": "gstreamer1.0-plugins-base",
|
|
90
|
+
"x264enc": "gstreamer1.0-plugins-ugly",
|
|
91
|
+
"x265enc": "gstreamer1.0-plugins-ugly",
|
|
92
|
+
"lamemp3enc": "gstreamer1.0-plugins-ugly",
|
|
93
|
+
"webrtcbin": "gstreamer1.0-plugins-bad",
|
|
94
|
+
"dtlssrtpdec": "gstreamer1.0-plugins-bad",
|
|
95
|
+
"srtpdec": "gstreamer1.0-plugins-bad",
|
|
96
|
+
"h264parse": "gstreamer1.0-plugins-bad",
|
|
97
|
+
"decodebin": "gstreamer1.0-plugins-base",
|
|
98
|
+
"decodebin3": "gstreamer1.0-plugins-base",
|
|
99
|
+
"avdec_h264": "gstreamer1.0-libav",
|
|
100
|
+
"avdec_h265": "gstreamer1.0-libav",
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# Collect suggested packages (deduped, preserving order)
|
|
104
|
+
seen_pkgs: Dict[str, bool] = {}
|
|
105
|
+
for name in missing:
|
|
106
|
+
pkg = _PLUGIN_HINTS.get(name)
|
|
107
|
+
if pkg and pkg not in seen_pkgs:
|
|
108
|
+
seen_pkgs[pkg] = True
|
|
109
|
+
|
|
110
|
+
lines = [
|
|
111
|
+
f"Missing GStreamer elements: {', '.join(missing)}",
|
|
112
|
+
"",
|
|
113
|
+
"These elements were not found on this system.",
|
|
114
|
+
"Verify each one with: gst-inspect-1.0 <element-name>",
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
if seen_pkgs:
|
|
118
|
+
lines += [
|
|
119
|
+
"",
|
|
120
|
+
"Suggested packages to install:",
|
|
121
|
+
" sudo apt update && sudo apt install -y " + " ".join(seen_pkgs),
|
|
122
|
+
]
|
|
123
|
+
else:
|
|
124
|
+
lines += [
|
|
125
|
+
"",
|
|
126
|
+
"Install the relevant GStreamer plugin packages for your distribution.",
|
|
127
|
+
"On Debian/Ubuntu: sudo apt install gstreamer1.0-plugins-{base,good,bad,ugly}",
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
raise EnvironmentError("\n".join(lines))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def check_v4l2_device(device: str = "/dev/video0") -> Tuple[bool, str]:
|
|
134
|
+
"""
|
|
135
|
+
Check whether a V4L2 video device exists and is readable.
|
|
136
|
+
|
|
137
|
+
This is a lightweight check — it verifies the device file exists and
|
|
138
|
+
that a 1-buffer test pipeline can open it. It does NOT try to capture
|
|
139
|
+
a full frame, so it returns quickly.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
device: The device path, e.g. "/dev/video0".
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
A (success, message) tuple. success is True if the device is usable.
|
|
146
|
+
message is a human-readable description of what was found or what
|
|
147
|
+
went wrong — suitable for logging or displaying to the user.
|
|
148
|
+
|
|
149
|
+
Example:
|
|
150
|
+
ok, msg = check_v4l2_device("/dev/video0")
|
|
151
|
+
if not ok:
|
|
152
|
+
print(f"Camera not available: {msg}")
|
|
153
|
+
sys.exit(1)
|
|
154
|
+
print(msg) # e.g. "Device /dev/video0 is accessible"
|
|
155
|
+
"""
|
|
156
|
+
if not os.path.exists(device):
|
|
157
|
+
return False, f"Device {device!r} does not exist"
|
|
158
|
+
|
|
159
|
+
if not os.access(device, os.R_OK):
|
|
160
|
+
return False, (
|
|
161
|
+
f"Device {device!r} exists but is not readable. "
|
|
162
|
+
f"Try: sudo usermod -aG video $USER (then re-login)"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# Attempt a 1-buffer pipeline to confirm the device actually opens.
|
|
166
|
+
# This catches "device busy" and driver errors that file permissions can't detect.
|
|
167
|
+
test_pipe = None
|
|
168
|
+
try:
|
|
169
|
+
test_pipe = Gst.parse_launch(
|
|
170
|
+
f"v4l2src device={device} num-buffers=1 ! fakesink"
|
|
171
|
+
)
|
|
172
|
+
ret = test_pipe.set_state(Gst.State.PLAYING)
|
|
173
|
+
if ret == Gst.StateChangeReturn.FAILURE:
|
|
174
|
+
return False, (
|
|
175
|
+
f"Device {device!r} could not be opened by GStreamer. "
|
|
176
|
+
f"It may be in use by another process."
|
|
177
|
+
)
|
|
178
|
+
return True, f"Device {device!r} is accessible"
|
|
179
|
+
except Exception as exc:
|
|
180
|
+
return False, f"Device {device!r} test failed: {exc}"
|
|
181
|
+
finally:
|
|
182
|
+
if test_pipe is not None:
|
|
183
|
+
test_pipe.set_state(Gst.State.NULL)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def list_v4l2_devices() -> List[str]:
|
|
187
|
+
"""
|
|
188
|
+
Return a list of V4L2 video device paths available on this system.
|
|
189
|
+
|
|
190
|
+
Scans /dev/video* and returns paths that exist. Does not verify that
|
|
191
|
+
each device is actually openable — use check_v4l2_device() for that.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
A sorted list of device paths, e.g. ["/dev/video0", "/dev/video2"].
|
|
195
|
+
Returns an empty list if no devices are found.
|
|
196
|
+
"""
|
|
197
|
+
import glob
|
|
198
|
+
return sorted(glob.glob("/dev/video*"))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
202
|
+
# Part 2: PTZ camera control
|
|
203
|
+
#
|
|
204
|
+
# V4L2PTZCamera wraps v4l2-ctl to control pan, tilt, and zoom on any
|
|
205
|
+
# camera that exposes those controls via the V4L2 extended controls API.
|
|
206
|
+
# Tested with Obsbot Tail Air, Obsbot Meet 4K, Logitech PTZ Pro, and similar.
|
|
207
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
@dataclass
|
|
210
|
+
class ControlRange:
|
|
211
|
+
"""The valid range for a single PTZ control axis, as reported by the camera."""
|
|
212
|
+
min: int
|
|
213
|
+
max: int
|
|
214
|
+
step: int
|
|
215
|
+
current: int
|
|
216
|
+
|
|
217
|
+
def clamp(self, value: int) -> int:
|
|
218
|
+
"""Clamp a value to this range, snapping to the nearest valid step."""
|
|
219
|
+
clamped = max(self.min, min(self.max, value))
|
|
220
|
+
# Snap to nearest valid step from the minimum
|
|
221
|
+
if self.step > 0:
|
|
222
|
+
offset = clamped - self.min
|
|
223
|
+
snapped = self.min + round(offset / self.step) * self.step
|
|
224
|
+
return max(self.min, min(self.max, snapped))
|
|
225
|
+
return clamped
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def span(self) -> int:
|
|
229
|
+
"""The total range from min to max."""
|
|
230
|
+
return self.max - self.min
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@dataclass
|
|
234
|
+
class PTZStatus:
|
|
235
|
+
"""A snapshot of the camera's current state, including all ranges."""
|
|
236
|
+
available: bool
|
|
237
|
+
device: str
|
|
238
|
+
pan: int = 0
|
|
239
|
+
tilt: int = 0
|
|
240
|
+
zoom: int = 0
|
|
241
|
+
pan_range: Optional[ControlRange] = None
|
|
242
|
+
tilt_range: Optional[ControlRange] = None
|
|
243
|
+
zoom_range: Optional[ControlRange] = None
|
|
244
|
+
|
|
245
|
+
def to_dict(self) -> dict:
|
|
246
|
+
"""Serialize to a plain dict — convenient for sending over WebSocket."""
|
|
247
|
+
return {
|
|
248
|
+
"available": self.available,
|
|
249
|
+
"device": self.device,
|
|
250
|
+
"pan": self.pan,
|
|
251
|
+
"tilt": self.tilt,
|
|
252
|
+
"zoom": self.zoom,
|
|
253
|
+
"ranges": {
|
|
254
|
+
"pan": {"min": self.pan_range.min, "max": self.pan_range.max} if self.pan_range else None,
|
|
255
|
+
"tilt": {"min": self.tilt_range.min, "max": self.tilt_range.max} if self.tilt_range else None,
|
|
256
|
+
"zoom": {"min": self.zoom_range.min, "max": self.zoom_range.max} if self.zoom_range else None,
|
|
257
|
+
},
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class V4L2PTZCamera:
|
|
262
|
+
"""
|
|
263
|
+
Control pan, tilt, and zoom on any V4L2 camera that supports those controls.
|
|
264
|
+
|
|
265
|
+
On creation, the class uses v4l2-ctl to auto-detect which controls the
|
|
266
|
+
camera exposes and what ranges are valid for each. This means the same
|
|
267
|
+
class works with any PTZ camera without you needing to hardcode min/max
|
|
268
|
+
values per camera model.
|
|
269
|
+
|
|
270
|
+
All set_*() methods silently clamp values to the camera's valid range,
|
|
271
|
+
so callers don't need to guard against out-of-bounds values. If a control
|
|
272
|
+
is not available on the connected camera, the method does nothing and
|
|
273
|
+
returns False.
|
|
274
|
+
|
|
275
|
+
If the camera is not connected, all operations are no-ops. This is
|
|
276
|
+
intentional — it allows code that uses PTZ control to start successfully
|
|
277
|
+
even when no PTZ camera is plugged in, and add the camera later.
|
|
278
|
+
|
|
279
|
+
Example:
|
|
280
|
+
cam = V4L2PTZCamera("/dev/video0")
|
|
281
|
+
|
|
282
|
+
if cam.available:
|
|
283
|
+
print(cam.status)
|
|
284
|
+
cam.set_pan(90_000) # tilt right (units are arc-seconds * 100)
|
|
285
|
+
cam.set_tilt(-50_000) # tilt down
|
|
286
|
+
cam.set_zoom(200) # 2x zoom (if zoom range is 100–400)
|
|
287
|
+
cam.reset() # back to center, minimum zoom
|
|
288
|
+
|
|
289
|
+
# Or set everything at once:
|
|
290
|
+
cam.set_ptz(pan=0, tilt=0, zoom=100)
|
|
291
|
+
"""
|
|
292
|
+
|
|
293
|
+
# Default fallback ranges used when auto-detection fails or a control
|
|
294
|
+
# isn't listed. These are typical values for mid-range PTZ cameras.
|
|
295
|
+
_DEFAULT_PAN_MIN = -468_000
|
|
296
|
+
_DEFAULT_PAN_MAX = 468_000
|
|
297
|
+
_DEFAULT_TILT_MIN = -324_000
|
|
298
|
+
_DEFAULT_TILT_MAX = 324_000
|
|
299
|
+
_DEFAULT_ZOOM_MIN = 100
|
|
300
|
+
_DEFAULT_ZOOM_MAX = 400
|
|
301
|
+
|
|
302
|
+
def __init__(self, device: str = "/dev/video0"):
|
|
303
|
+
self.device = device
|
|
304
|
+
self.available = False
|
|
305
|
+
|
|
306
|
+
# Ranges — populated by _detect_ranges(), defaulted here so that
|
|
307
|
+
# all attributes always exist even if detection fails
|
|
308
|
+
self.pan_range = ControlRange(self._DEFAULT_PAN_MIN, self._DEFAULT_PAN_MAX, 3600, 0)
|
|
309
|
+
self.tilt_range = ControlRange(self._DEFAULT_TILT_MIN, self._DEFAULT_TILT_MAX, 3600, 0)
|
|
310
|
+
self.zoom_range = ControlRange(self._DEFAULT_ZOOM_MIN, self._DEFAULT_ZOOM_MAX, 1, self._DEFAULT_ZOOM_MIN)
|
|
311
|
+
|
|
312
|
+
# Current values — updated on every successful set_*() call
|
|
313
|
+
self._pan = 0
|
|
314
|
+
self._tilt = 0
|
|
315
|
+
self._zoom = self._DEFAULT_ZOOM_MIN
|
|
316
|
+
|
|
317
|
+
self._check_availability()
|
|
318
|
+
if self.available:
|
|
319
|
+
self._detect_ranges()
|
|
320
|
+
|
|
321
|
+
# ── Public API ────────────────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def pan(self) -> int:
|
|
325
|
+
return self._pan
|
|
326
|
+
|
|
327
|
+
@property
|
|
328
|
+
def tilt(self) -> int:
|
|
329
|
+
return self._tilt
|
|
330
|
+
|
|
331
|
+
@property
|
|
332
|
+
def zoom(self) -> int:
|
|
333
|
+
return self._zoom
|
|
334
|
+
|
|
335
|
+
@property
|
|
336
|
+
def status(self) -> PTZStatus:
|
|
337
|
+
"""Return the current camera state as a PTZStatus dataclass."""
|
|
338
|
+
return PTZStatus(
|
|
339
|
+
available=self.available,
|
|
340
|
+
device=self.device,
|
|
341
|
+
pan=self._pan,
|
|
342
|
+
tilt=self._tilt,
|
|
343
|
+
zoom=self._zoom,
|
|
344
|
+
pan_range=self.pan_range,
|
|
345
|
+
tilt_range=self.tilt_range,
|
|
346
|
+
zoom_range=self.zoom_range,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
def set_pan(self, value: int) -> bool:
|
|
350
|
+
"""
|
|
351
|
+
Set the pan position. Value is clamped to the camera's valid range.
|
|
352
|
+
Units are camera-specific (typically arc-seconds × 100 for most PTZ cameras).
|
|
353
|
+
|
|
354
|
+
Returns True on success, False if the camera is unavailable or the
|
|
355
|
+
control failed.
|
|
356
|
+
"""
|
|
357
|
+
if not self.available:
|
|
358
|
+
return False
|
|
359
|
+
value = self.pan_range.clamp(value)
|
|
360
|
+
if self._set_ctrl("pan_absolute", value):
|
|
361
|
+
self._pan = value
|
|
362
|
+
return True
|
|
363
|
+
return False
|
|
364
|
+
|
|
365
|
+
def set_tilt(self, value: int) -> bool:
|
|
366
|
+
"""
|
|
367
|
+
Set the tilt position. Value is clamped to the camera's valid range.
|
|
368
|
+
|
|
369
|
+
Returns True on success, False if the camera is unavailable or the
|
|
370
|
+
control failed.
|
|
371
|
+
"""
|
|
372
|
+
if not self.available:
|
|
373
|
+
return False
|
|
374
|
+
value = self.tilt_range.clamp(value)
|
|
375
|
+
if self._set_ctrl("tilt_absolute", value):
|
|
376
|
+
self._tilt = value
|
|
377
|
+
return True
|
|
378
|
+
return False
|
|
379
|
+
|
|
380
|
+
def set_zoom(self, value: int) -> bool:
|
|
381
|
+
"""
|
|
382
|
+
Set the zoom level. Value is clamped to the camera's valid range.
|
|
383
|
+
|
|
384
|
+
Returns True on success, False if the camera is unavailable or the
|
|
385
|
+
control failed.
|
|
386
|
+
"""
|
|
387
|
+
if not self.available:
|
|
388
|
+
return False
|
|
389
|
+
value = self.zoom_range.clamp(value)
|
|
390
|
+
if self._set_ctrl("zoom_absolute", value):
|
|
391
|
+
self._zoom = value
|
|
392
|
+
return True
|
|
393
|
+
return False
|
|
394
|
+
|
|
395
|
+
def set_ptz(self, pan: int, tilt: int, zoom: int) -> bool:
|
|
396
|
+
"""
|
|
397
|
+
Set pan, tilt, and zoom in one call. All values are clamped.
|
|
398
|
+
|
|
399
|
+
Returns True only if all three controls succeeded.
|
|
400
|
+
"""
|
|
401
|
+
ok_pan = self.set_pan(pan)
|
|
402
|
+
ok_tilt = self.set_tilt(tilt)
|
|
403
|
+
ok_zoom = self.set_zoom(zoom)
|
|
404
|
+
return ok_pan and ok_tilt and ok_zoom
|
|
405
|
+
|
|
406
|
+
def reset(self) -> bool:
|
|
407
|
+
"""Move to center position (pan=0, tilt=0) at minimum zoom."""
|
|
408
|
+
return self.set_ptz(0, 0, self.zoom_range.min)
|
|
409
|
+
|
|
410
|
+
# ── Internal helpers ──────────────────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
def _check_availability(self) -> None:
|
|
413
|
+
"""Use v4l2-ctl --info to verify the device is reachable."""
|
|
414
|
+
try:
|
|
415
|
+
result = subprocess.run(
|
|
416
|
+
["v4l2-ctl", "-d", self.device, "--info"],
|
|
417
|
+
capture_output=True, text=True, timeout=2,
|
|
418
|
+
)
|
|
419
|
+
self.available = (result.returncode == 0)
|
|
420
|
+
except FileNotFoundError:
|
|
421
|
+
# v4l2-ctl is not installed
|
|
422
|
+
self.available = False
|
|
423
|
+
except Exception:
|
|
424
|
+
self.available = False
|
|
425
|
+
|
|
426
|
+
def _detect_ranges(self) -> None:
|
|
427
|
+
"""
|
|
428
|
+
Parse v4l2-ctl --list-ctrls to find valid ranges for pan, tilt, zoom.
|
|
429
|
+
|
|
430
|
+
The output looks like:
|
|
431
|
+
pan_absolute 0x009a0908 (int) : min=-522000 max=522000 step=3600 default=0 value=0
|
|
432
|
+
tilt_absolute 0x009a0909 (int) : min=-270000 max=270000 step=3600 default=0 value=0
|
|
433
|
+
zoom_absolute 0x009a090d (int) : min=100 max=400 step=10 default=100 value=100
|
|
434
|
+
|
|
435
|
+
We parse min, max, step, and current value for each control we care
|
|
436
|
+
about and store them in ControlRange instances.
|
|
437
|
+
"""
|
|
438
|
+
try:
|
|
439
|
+
result = subprocess.run(
|
|
440
|
+
["v4l2-ctl", "-d", self.device, "--list-ctrls"],
|
|
441
|
+
capture_output=True, text=True, timeout=2,
|
|
442
|
+
)
|
|
443
|
+
if result.returncode != 0:
|
|
444
|
+
return
|
|
445
|
+
|
|
446
|
+
for line in result.stdout.splitlines():
|
|
447
|
+
line = line.strip()
|
|
448
|
+
|
|
449
|
+
if "pan_absolute" in line:
|
|
450
|
+
parsed = _parse_v4l2_ctrl_line(line)
|
|
451
|
+
if parsed:
|
|
452
|
+
self.pan_range = parsed
|
|
453
|
+
self._pan = parsed.current
|
|
454
|
+
|
|
455
|
+
elif "tilt_absolute" in line:
|
|
456
|
+
parsed = _parse_v4l2_ctrl_line(line)
|
|
457
|
+
if parsed:
|
|
458
|
+
self.tilt_range = parsed
|
|
459
|
+
self._tilt = parsed.current
|
|
460
|
+
|
|
461
|
+
elif "zoom_absolute" in line:
|
|
462
|
+
parsed = _parse_v4l2_ctrl_line(line)
|
|
463
|
+
if parsed:
|
|
464
|
+
self.zoom_range = parsed
|
|
465
|
+
self._zoom = parsed.current
|
|
466
|
+
|
|
467
|
+
except Exception:
|
|
468
|
+
# If detection fails for any reason, we keep the defaults.
|
|
469
|
+
# This is a deliberate silent failure — PTZ detection failing
|
|
470
|
+
# should not crash the application.
|
|
471
|
+
pass
|
|
472
|
+
|
|
473
|
+
def _set_ctrl(self, control_name: str, value: int) -> bool:
|
|
474
|
+
"""
|
|
475
|
+
Run v4l2-ctl --set-ctrl to apply a single control value.
|
|
476
|
+
Returns True on success, False on any failure.
|
|
477
|
+
"""
|
|
478
|
+
try:
|
|
479
|
+
result = subprocess.run(
|
|
480
|
+
["v4l2-ctl", "-d", self.device,
|
|
481
|
+
"--set-ctrl", f"{control_name}={value}"],
|
|
482
|
+
capture_output=True, text=True, timeout=1,
|
|
483
|
+
)
|
|
484
|
+
return result.returncode == 0
|
|
485
|
+
except Exception:
|
|
486
|
+
return False
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _parse_v4l2_ctrl_line(line: str) -> Optional[ControlRange]:
|
|
490
|
+
"""
|
|
491
|
+
Parse a single line of v4l2-ctl --list-ctrls output into a ControlRange.
|
|
492
|
+
|
|
493
|
+
The format is:
|
|
494
|
+
<name> <hex_id> (<type>) : min=<N> max=<N> step=<N> default=<N> value=<N>
|
|
495
|
+
|
|
496
|
+
We only need min, max, step, and the current value. Returns None if
|
|
497
|
+
any required field is missing or unparseable.
|
|
498
|
+
"""
|
|
499
|
+
fields: Dict[str, int] = {}
|
|
500
|
+
for token in line.split():
|
|
501
|
+
for key in ("min", "max", "step", "value"):
|
|
502
|
+
if token.startswith(f"{key}="):
|
|
503
|
+
try:
|
|
504
|
+
fields[key] = int(token.split("=", 1)[1])
|
|
505
|
+
except ValueError:
|
|
506
|
+
pass
|
|
507
|
+
|
|
508
|
+
required = ("min", "max", "step", "value")
|
|
509
|
+
if not all(k in fields for k in required):
|
|
510
|
+
return None
|
|
511
|
+
|
|
512
|
+
return ControlRange(
|
|
513
|
+
min=fields["min"],
|
|
514
|
+
max=fields["max"],
|
|
515
|
+
step=fields["step"],
|
|
516
|
+
current=fields["value"],
|
|
517
|
+
)
|