OctoPrint-BitBang 0.1.2__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.
- octoprint_bitbang/__init__.py +499 -0
- octoprint_bitbang/__main__.py +2 -0
- octoprint_bitbang/app.py +86 -0
- octoprint_bitbang/camera.py +117 -0
- octoprint_bitbang/flip_track.py +59 -0
- octoprint_bitbang/index.html +16 -0
- octoprint_bitbang/octoprint_adapter.py +103 -0
- octoprint_bitbang/pi_camera_track.py +48 -0
- octoprint_bitbang/pi_h264_source.py +149 -0
- octoprint_bitbang/static/favicon.png +0 -0
- octoprint_bitbang/static/js/bitbang.js +249 -0
- octoprint_bitbang/templates/bitbang_navbar.jinja2 +29 -0
- octoprint_bitbang/templates/bitbang_settings.jinja2 +144 -0
- octoprint_bitbang/usb_camera_source.py +71 -0
- octoprint_bitbang-0.1.2.dist-info/METADATA +152 -0
- octoprint_bitbang-0.1.2.dist-info/RECORD +20 -0
- octoprint_bitbang-0.1.2.dist-info/WHEEL +5 -0
- octoprint_bitbang-0.1.2.dist-info/entry_points.txt +5 -0
- octoprint_bitbang-0.1.2.dist-info/licenses/LICENSE +21 -0
- octoprint_bitbang-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
"""OctoPrint-BitBang plugin.
|
|
2
|
+
|
|
3
|
+
Remote OctoPrint access with live H.264 video via BitBang WebRTC.
|
|
4
|
+
No account, no subscription, no port forwarding. One shareable link.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__plugin_name__ = "BitBang"
|
|
8
|
+
__plugin_version__ = "0.1.2"
|
|
9
|
+
__plugin_description__ = "Remote OctoPrint access with live H.264 video via BitBang WebRTC. No account, no port forwarding, one shareable link."
|
|
10
|
+
__plugin_url__ = "https://github.com/richlegrand/OctoPrint-BitBang"
|
|
11
|
+
__plugin_author__ = "Rich LeGrand"
|
|
12
|
+
__plugin_license__ = "MIT"
|
|
13
|
+
__plugin_privacypolicy__ = "https://github.com/richlegrand/OctoPrint-BitBang/blob/main/PRIVACY.md"
|
|
14
|
+
__plugin_pythoncompat__ = ">=3.10,<4"
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import threading
|
|
18
|
+
import octoprint.plugin
|
|
19
|
+
|
|
20
|
+
from bitbang.proxy import ReverseProxyASGI
|
|
21
|
+
from .octoprint_adapter import OctoPrintBitBang
|
|
22
|
+
from .camera import detect_camera
|
|
23
|
+
|
|
24
|
+
import flask
|
|
25
|
+
import asyncio
|
|
26
|
+
from aiortc import RTCPeerConnection, RTCSessionDescription
|
|
27
|
+
from aiortc.contrib.media import MediaRelay
|
|
28
|
+
|
|
29
|
+
from octoprint.schema.webcam import Webcam, WebcamCompatibility
|
|
30
|
+
|
|
31
|
+
class BitBangPlugin(
|
|
32
|
+
octoprint.plugin.StartupPlugin,
|
|
33
|
+
octoprint.plugin.ShutdownPlugin,
|
|
34
|
+
octoprint.plugin.SettingsPlugin,
|
|
35
|
+
octoprint.plugin.TemplatePlugin,
|
|
36
|
+
octoprint.plugin.AssetPlugin,
|
|
37
|
+
octoprint.plugin.BlueprintPlugin,
|
|
38
|
+
octoprint.plugin.WebcamProviderPlugin,
|
|
39
|
+
):
|
|
40
|
+
def __init__(self):
|
|
41
|
+
super().__init__()
|
|
42
|
+
self._adapter = None
|
|
43
|
+
self._thread = None
|
|
44
|
+
self._local_pcs = set() # track local WebRTC peer connections
|
|
45
|
+
|
|
46
|
+
def on_after_startup(self):
|
|
47
|
+
self._probe_picamera2_sensor()
|
|
48
|
+
if not self._settings.get_boolean(["enabled"]):
|
|
49
|
+
self._logger.info("BitBang disabled in settings")
|
|
50
|
+
return
|
|
51
|
+
self._start_bitbang()
|
|
52
|
+
|
|
53
|
+
def _probe_picamera2_sensor(self):
|
|
54
|
+
# Cache before the adapter opens the camera — picamera2 can't be
|
|
55
|
+
# opened twice, so the resolutions endpoint relies on this.
|
|
56
|
+
self._picam2_sensor_size = None
|
|
57
|
+
try:
|
|
58
|
+
from picamera2 import Picamera2
|
|
59
|
+
cam = Picamera2()
|
|
60
|
+
self._picam2_sensor_size = cam.sensor_resolution
|
|
61
|
+
cam.close()
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
def _start_bitbang(self):
|
|
66
|
+
port = self._settings.global_get(["server", "port"]) or 5000
|
|
67
|
+
proxy_app = ReverseProxyASGI(f"localhost:{port}")
|
|
68
|
+
|
|
69
|
+
# Use configured camera or auto-detect
|
|
70
|
+
camera_device = self._settings.get(["camera_device"])
|
|
71
|
+
camera_resolution = self._settings.get(["camera_resolution"]) or "640x480"
|
|
72
|
+
|
|
73
|
+
flip_h = self._settings.get_boolean(["flip_horizontal"])
|
|
74
|
+
flip_v = self._settings.get_boolean(["flip_vertical"])
|
|
75
|
+
|
|
76
|
+
brightness = self._settings.get_int(["brightness"]) or 0
|
|
77
|
+
|
|
78
|
+
if camera_device == "picamera2":
|
|
79
|
+
w, h = (int(x) for x in camera_resolution.split("x"))
|
|
80
|
+
camera = {
|
|
81
|
+
"type": "picamera2",
|
|
82
|
+
"size": (w, h),
|
|
83
|
+
"flip_horizontal": flip_h,
|
|
84
|
+
"flip_vertical": flip_v,
|
|
85
|
+
"brightness": brightness,
|
|
86
|
+
}
|
|
87
|
+
self._logger.info(f"Camera: picamera2 at {camera_resolution}")
|
|
88
|
+
elif camera_device:
|
|
89
|
+
camera = {
|
|
90
|
+
"type": "usb",
|
|
91
|
+
"device": camera_device,
|
|
92
|
+
"format": "v4l2",
|
|
93
|
+
"options": {"framerate": "30", "video_size": camera_resolution},
|
|
94
|
+
"flip_horizontal": flip_h,
|
|
95
|
+
"flip_vertical": flip_v,
|
|
96
|
+
"brightness": brightness,
|
|
97
|
+
}
|
|
98
|
+
self._logger.info(f"Camera: {camera_device} at {camera_resolution}")
|
|
99
|
+
else:
|
|
100
|
+
camera = detect_camera(logger=self._logger)
|
|
101
|
+
if camera:
|
|
102
|
+
camera["flip_horizontal"] = flip_h
|
|
103
|
+
camera["flip_vertical"] = flip_v
|
|
104
|
+
camera["brightness"] = brightness
|
|
105
|
+
if camera["type"] == "picamera2":
|
|
106
|
+
w, h = (int(x) for x in camera_resolution.split("x"))
|
|
107
|
+
camera["size"] = (w, h)
|
|
108
|
+
else:
|
|
109
|
+
camera.setdefault("options", {})["video_size"] = camera_resolution
|
|
110
|
+
self._logger.info(f"Camera: {camera['type']} at {camera_resolution}")
|
|
111
|
+
else:
|
|
112
|
+
self._logger.info("No camera detected, HTTP-only mode")
|
|
113
|
+
|
|
114
|
+
pin = self._settings.get(["pin"]) or None
|
|
115
|
+
|
|
116
|
+
self._adapter = OctoPrintBitBang(
|
|
117
|
+
proxy_app,
|
|
118
|
+
camera_source=camera,
|
|
119
|
+
ws_target=f"localhost:{port}",
|
|
120
|
+
program_name="octoprint",
|
|
121
|
+
pin=pin,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Route the adapter's connection-request event into OctoPrint's
|
|
125
|
+
# structured logger so the connecting browser IP shows up in
|
|
126
|
+
# octoprint.log (and any plugin-log filter) rather than only
|
|
127
|
+
# appearing in stdout/journald.
|
|
128
|
+
@self._adapter.on_connection_request
|
|
129
|
+
def _log_connection_request(client_id, browser_ip):
|
|
130
|
+
self._logger.info(
|
|
131
|
+
f"Connection request from {client_id} (browser_ip={browser_ip})"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
self._thread = threading.Thread(
|
|
135
|
+
target=self._adapter.run,
|
|
136
|
+
daemon=True,
|
|
137
|
+
name="BitBangThread",
|
|
138
|
+
)
|
|
139
|
+
self._thread.start()
|
|
140
|
+
|
|
141
|
+
url = self._adapter.url
|
|
142
|
+
self._settings.set(["url"], url)
|
|
143
|
+
self._settings.save()
|
|
144
|
+
self._logger.info(f"BitBang remote access: {url}")
|
|
145
|
+
|
|
146
|
+
# -- Local WebRTC video signaling --
|
|
147
|
+
|
|
148
|
+
@octoprint.plugin.BlueprintPlugin.route("/ice-servers", methods=["GET"])
|
|
149
|
+
def get_ice_servers(self):
|
|
150
|
+
"""Return TURN/STUN servers for local video WebRTC."""
|
|
151
|
+
servers = self._adapter.get_ice_servers() if self._adapter else []
|
|
152
|
+
return flask.jsonify(servers)
|
|
153
|
+
|
|
154
|
+
@octoprint.plugin.BlueprintPlugin.route("/offer", methods=["POST"])
|
|
155
|
+
@octoprint.plugin.BlueprintPlugin.csrf_exempt()
|
|
156
|
+
def local_offer(self):
|
|
157
|
+
"""Exchange WebRTC SDP for local H.264 video streaming."""
|
|
158
|
+
if not self._adapter or not self._adapter.player or not self._adapter.player.video:
|
|
159
|
+
return flask.jsonify({"error": "no camera"}), 503
|
|
160
|
+
|
|
161
|
+
offer_sdp = flask.request.json.get("sdp")
|
|
162
|
+
offer_type = flask.request.json.get("type", "offer")
|
|
163
|
+
if not offer_sdp:
|
|
164
|
+
return flask.jsonify({"error": "missing sdp"}), 400
|
|
165
|
+
|
|
166
|
+
# Run the async WebRTC handshake in the adapter's event loop
|
|
167
|
+
loop = self._adapter._loop
|
|
168
|
+
if not loop:
|
|
169
|
+
return flask.jsonify({"error": "not ready"}), 503
|
|
170
|
+
|
|
171
|
+
ice_servers = self._adapter.get_ice_servers()
|
|
172
|
+
|
|
173
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
174
|
+
self._handle_local_offer(offer_sdp, offer_type, ice_servers), loop
|
|
175
|
+
)
|
|
176
|
+
try:
|
|
177
|
+
answer = future.result(timeout=10)
|
|
178
|
+
answer['ice_servers'] = ice_servers
|
|
179
|
+
return flask.jsonify(answer)
|
|
180
|
+
except Exception as e:
|
|
181
|
+
self._logger.error(f"Local WebRTC offer failed: {e}")
|
|
182
|
+
return flask.jsonify({"error": str(e)}), 500
|
|
183
|
+
|
|
184
|
+
def _strip_non_h264(self, sdp):
|
|
185
|
+
"""Remove non-H.264 video codecs from an SDP so aiortc has no
|
|
186
|
+
choice but to negotiate H.264 (our track is pre-encoded H.264)."""
|
|
187
|
+
import re
|
|
188
|
+
lines = sdp.split("\r\n")
|
|
189
|
+
h264_pts = [m.group(1) for m in (re.match(r"a=rtpmap:(\d+) H264/", l) for l in lines) if m]
|
|
190
|
+
rtx_pts = [m.group(1) for m in (re.match(r"a=fmtp:(\d+) apt=(\d+)", l) for l in lines) if m and m.group(2) in h264_pts]
|
|
191
|
+
keep = set(h264_pts) | set(rtx_pts)
|
|
192
|
+
out = []
|
|
193
|
+
for line in lines:
|
|
194
|
+
m = re.match(r"(m=video \d+ \S+) (.+)", line)
|
|
195
|
+
if m:
|
|
196
|
+
header, pts = m.groups()
|
|
197
|
+
kept = [p for p in pts.split() if p in keep]
|
|
198
|
+
out.append(f"{header} {' '.join(kept)}")
|
|
199
|
+
continue
|
|
200
|
+
m = re.match(r"a=(rtpmap|fmtp|rtcp-fb):(\d+)", line)
|
|
201
|
+
if m and m.group(2) not in keep:
|
|
202
|
+
continue
|
|
203
|
+
out.append(line)
|
|
204
|
+
return "\r\n".join(out)
|
|
205
|
+
|
|
206
|
+
async def _handle_local_offer(self, offer_sdp, offer_type, ice_servers=None):
|
|
207
|
+
offer_sdp = self._strip_non_h264(offer_sdp)
|
|
208
|
+
config = self._adapter._build_rtc_config(ice_servers) if ice_servers else None
|
|
209
|
+
pc = RTCPeerConnection(config) if config else RTCPeerConnection()
|
|
210
|
+
self._local_pcs.add(pc)
|
|
211
|
+
|
|
212
|
+
@pc.on("connectionstatechange")
|
|
213
|
+
async def on_state():
|
|
214
|
+
if pc.connectionState in ("failed", "closed"):
|
|
215
|
+
self._local_pcs.discard(pc)
|
|
216
|
+
await pc.close()
|
|
217
|
+
|
|
218
|
+
# Order matters: set remote description first so aiortc creates
|
|
219
|
+
# the transceiver matching the client's mid. Then addTrack reuses
|
|
220
|
+
# it and setCodecPreferences applies to the right one. Otherwise
|
|
221
|
+
# the answer ends up negotiating VP8.
|
|
222
|
+
offer = RTCSessionDescription(sdp=offer_sdp, type=offer_type)
|
|
223
|
+
await pc.setRemoteDescription(offer)
|
|
224
|
+
|
|
225
|
+
sender = pc.addTrack(self._adapter.relay.subscribe(self._adapter.player.video))
|
|
226
|
+
|
|
227
|
+
from .octoprint_adapter import force_h264
|
|
228
|
+
force_h264(pc, sender)
|
|
229
|
+
|
|
230
|
+
answer = await pc.createAnswer()
|
|
231
|
+
await pc.setLocalDescription(answer)
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
"sdp": pc.localDescription.sdp,
|
|
235
|
+
"type": pc.localDescription.type,
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
# -- Camera settings API --
|
|
239
|
+
|
|
240
|
+
@octoprint.plugin.BlueprintPlugin.route("/camera/config", methods=["GET"])
|
|
241
|
+
def camera_config(self):
|
|
242
|
+
"""Return current camera display / tuning config for the client."""
|
|
243
|
+
return flask.jsonify({
|
|
244
|
+
"flip_horizontal": bool(self._settings.get_boolean(["flip_horizontal"])),
|
|
245
|
+
"flip_vertical": bool(self._settings.get_boolean(["flip_vertical"])),
|
|
246
|
+
"brightness": self._settings.get_int(["brightness"]) or 0,
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
@octoprint.plugin.BlueprintPlugin.route("/camera/brightness", methods=["POST"])
|
|
250
|
+
@octoprint.plugin.BlueprintPlugin.csrf_exempt()
|
|
251
|
+
def set_brightness(self):
|
|
252
|
+
"""Update camera brightness live. Accepts {"value": int -100..100}."""
|
|
253
|
+
try:
|
|
254
|
+
value = int(flask.request.json.get("value"))
|
|
255
|
+
except (TypeError, ValueError, AttributeError):
|
|
256
|
+
return flask.jsonify({"error": "missing or invalid value"}), 400
|
|
257
|
+
value = max(-100, min(100, value))
|
|
258
|
+
|
|
259
|
+
player = self._adapter.player if self._adapter else None
|
|
260
|
+
if player is None or not hasattr(player, "set_brightness"):
|
|
261
|
+
return flask.jsonify({"error": "brightness not supported on this camera"}), 400
|
|
262
|
+
|
|
263
|
+
if player.set_brightness(value) is False:
|
|
264
|
+
return flask.jsonify({"error": "brightness not supported on this camera"}), 400
|
|
265
|
+
self._settings.set_int(["brightness"], value)
|
|
266
|
+
self._settings.save()
|
|
267
|
+
return flask.jsonify({"value": value})
|
|
268
|
+
|
|
269
|
+
@octoprint.plugin.BlueprintPlugin.route("/cameras", methods=["GET"])
|
|
270
|
+
def list_cameras(self):
|
|
271
|
+
"""List available video capture devices."""
|
|
272
|
+
import subprocess
|
|
273
|
+
cameras = []
|
|
274
|
+
# Pi CSI camera surfaces through picamera2/libcamera, not v4l2-ctl
|
|
275
|
+
if getattr(self, "_picam2_sensor_size", None):
|
|
276
|
+
cameras.append({"device": "picamera2", "name": "Pi CSI camera"})
|
|
277
|
+
try:
|
|
278
|
+
result = subprocess.run(
|
|
279
|
+
["v4l2-ctl", "--list-devices"],
|
|
280
|
+
capture_output=True, text=True, timeout=5
|
|
281
|
+
)
|
|
282
|
+
current_name = None
|
|
283
|
+
for line in result.stdout.splitlines():
|
|
284
|
+
if not line.startswith("\t"):
|
|
285
|
+
current_name = line.strip().rstrip(":")
|
|
286
|
+
elif "/dev/video" in line:
|
|
287
|
+
dev = line.strip()
|
|
288
|
+
# Only include devices that have video formats
|
|
289
|
+
# (filters out metadata-only nodes like /dev/video1)
|
|
290
|
+
if self._has_video_formats(dev):
|
|
291
|
+
cameras.append({"device": dev, "name": current_name or dev})
|
|
292
|
+
except Exception as e:
|
|
293
|
+
self._logger.warning(f"Failed to list cameras: {e}")
|
|
294
|
+
return flask.jsonify(cameras)
|
|
295
|
+
|
|
296
|
+
@octoprint.plugin.BlueprintPlugin.route("/resolutions", methods=["GET"])
|
|
297
|
+
def list_resolutions(self):
|
|
298
|
+
"""List supported resolutions for a camera device."""
|
|
299
|
+
import subprocess
|
|
300
|
+
device = flask.request.args.get("device", "")
|
|
301
|
+
# picamera2: explicit Pi CSI selection or auto-detect when present
|
|
302
|
+
if device == "picamera2" or not device:
|
|
303
|
+
picam_res = self._picamera2_resolutions()
|
|
304
|
+
if picam_res is not None:
|
|
305
|
+
return flask.jsonify(picam_res)
|
|
306
|
+
if device == "picamera2":
|
|
307
|
+
return flask.jsonify([])
|
|
308
|
+
device = "/dev/video0"
|
|
309
|
+
resolutions = []
|
|
310
|
+
try:
|
|
311
|
+
result = subprocess.run(
|
|
312
|
+
["v4l2-ctl", "--list-formats-ext", "-d", device],
|
|
313
|
+
capture_output=True, text=True, timeout=5
|
|
314
|
+
)
|
|
315
|
+
seen = set()
|
|
316
|
+
for line in result.stdout.splitlines():
|
|
317
|
+
line = line.strip()
|
|
318
|
+
if line.startswith("Size: Discrete"):
|
|
319
|
+
res = line.split("Discrete")[1].strip()
|
|
320
|
+
if res in seen:
|
|
321
|
+
continue
|
|
322
|
+
seen.add(res)
|
|
323
|
+
try:
|
|
324
|
+
if int(res.split("x")[0]) > 1280:
|
|
325
|
+
continue
|
|
326
|
+
except ValueError:
|
|
327
|
+
continue
|
|
328
|
+
resolutions.append(res)
|
|
329
|
+
except Exception as e:
|
|
330
|
+
self._logger.warning(f"Failed to list resolutions: {e}")
|
|
331
|
+
# Sort by width
|
|
332
|
+
resolutions.sort(key=lambda r: int(r.split("x")[0]))
|
|
333
|
+
return flask.jsonify(resolutions)
|
|
334
|
+
|
|
335
|
+
# Standard resolutions offered for Pi CSI cameras, filtered by sensor
|
|
336
|
+
# max. Mix of 4:3 and 16:9 so users can pick their preferred ratio.
|
|
337
|
+
_PICAMERA2_STANDARD_RESOLUTIONS = [
|
|
338
|
+
(640, 480), (800, 600),
|
|
339
|
+
(1280, 720), (1280, 960),
|
|
340
|
+
]
|
|
341
|
+
|
|
342
|
+
def _picamera2_resolutions(self):
|
|
343
|
+
"""Return list of supported resolutions for the Pi CSI sensor, or None."""
|
|
344
|
+
sensor = getattr(self, "_picam2_sensor_size", None)
|
|
345
|
+
if not sensor:
|
|
346
|
+
return None
|
|
347
|
+
max_w, max_h = sensor
|
|
348
|
+
return [
|
|
349
|
+
f"{w}x{h}"
|
|
350
|
+
for (w, h) in self._PICAMERA2_STANDARD_RESOLUTIONS
|
|
351
|
+
if w <= max_w and h <= max_h
|
|
352
|
+
]
|
|
353
|
+
|
|
354
|
+
def _has_video_formats(self, device):
|
|
355
|
+
"""Check if a V4L2 device has any video capture formats."""
|
|
356
|
+
import subprocess
|
|
357
|
+
try:
|
|
358
|
+
result = subprocess.run(
|
|
359
|
+
["v4l2-ctl", "--list-formats-ext", "-d", device],
|
|
360
|
+
capture_output=True, text=True, timeout=5
|
|
361
|
+
)
|
|
362
|
+
return "Size: Discrete" in result.stdout
|
|
363
|
+
except Exception:
|
|
364
|
+
return False
|
|
365
|
+
|
|
366
|
+
# -- WebcamProviderPlugin API --
|
|
367
|
+
|
|
368
|
+
def get_webcam_configurations(self):
|
|
369
|
+
return [
|
|
370
|
+
Webcam(
|
|
371
|
+
name="bitbang",
|
|
372
|
+
displayName="BitBang Camera",
|
|
373
|
+
canSnapshot=True,
|
|
374
|
+
snapshotDisplay="BitBang plugin captures snapshot from video stream",
|
|
375
|
+
)
|
|
376
|
+
]
|
|
377
|
+
|
|
378
|
+
def take_webcam_snapshot(self, webcamName):
|
|
379
|
+
"""Grab a frame from the video track and return JPEG bytes."""
|
|
380
|
+
from octoprint.webcams import WebcamNotAbleToTakeSnapshotException
|
|
381
|
+
|
|
382
|
+
if not self._adapter or not self._adapter.player or not self._adapter.player.video:
|
|
383
|
+
raise WebcamNotAbleToTakeSnapshotException(webcamName)
|
|
384
|
+
|
|
385
|
+
player = self._adapter.player
|
|
386
|
+
try:
|
|
387
|
+
# Pi CSI: grab directly via picamera2 (no H.264 decode) so we
|
|
388
|
+
# stay within the Pi 4 CPU budget. USB falls through to the
|
|
389
|
+
# relay-based decoded-frame path.
|
|
390
|
+
if hasattr(player, "capture_snapshot"):
|
|
391
|
+
return iter([player.capture_snapshot()])
|
|
392
|
+
|
|
393
|
+
loop = self._adapter._loop
|
|
394
|
+
if not loop:
|
|
395
|
+
raise WebcamNotAbleToTakeSnapshotException(webcamName)
|
|
396
|
+
future = asyncio.run_coroutine_threadsafe(self._capture_frame(), loop)
|
|
397
|
+
return iter([future.result(timeout=5)])
|
|
398
|
+
except WebcamNotAbleToTakeSnapshotException:
|
|
399
|
+
raise
|
|
400
|
+
except Exception as e:
|
|
401
|
+
self._logger.error(f"Snapshot failed: {e}")
|
|
402
|
+
raise WebcamNotAbleToTakeSnapshotException(webcamName)
|
|
403
|
+
|
|
404
|
+
async def _capture_frame(self):
|
|
405
|
+
"""Grab one frame from the video relay and encode as JPEG."""
|
|
406
|
+
import io as _io
|
|
407
|
+
import av as _av
|
|
408
|
+
|
|
409
|
+
# Subscribe to the relay to get a frame without
|
|
410
|
+
# stealing from existing WebRTC consumers
|
|
411
|
+
track = self._adapter.relay.subscribe(self._adapter.player.video)
|
|
412
|
+
try:
|
|
413
|
+
frame = await track.recv()
|
|
414
|
+
finally:
|
|
415
|
+
track.stop()
|
|
416
|
+
|
|
417
|
+
# CRITICAL: copy the raw plane data immediately. The relay
|
|
418
|
+
# shares frame buffers with the encoder -- any sws_scale call
|
|
419
|
+
# (to_ndarray, to_image, reformat) will segfault if the encoder
|
|
420
|
+
# is concurrently accessing the same buffer.
|
|
421
|
+
planes_data = [bytes(frame.planes[i]) for i in range(len(frame.planes))]
|
|
422
|
+
width, height = frame.width, frame.height
|
|
423
|
+
fmt = frame.format.name
|
|
424
|
+
|
|
425
|
+
# Build a new independent frame from the copied data
|
|
426
|
+
new_frame = _av.VideoFrame(width, height, fmt)
|
|
427
|
+
for i, data in enumerate(planes_data):
|
|
428
|
+
new_frame.planes[i].update(data)
|
|
429
|
+
|
|
430
|
+
# Now safe to convert -- this frame's buffer is ours alone
|
|
431
|
+
buf = _io.BytesIO()
|
|
432
|
+
new_frame.to_image().save(buf, format="JPEG", quality=85)
|
|
433
|
+
return buf.getvalue()
|
|
434
|
+
|
|
435
|
+
def on_shutdown(self):
|
|
436
|
+
pass # Daemon thread exits with OctoPrint
|
|
437
|
+
|
|
438
|
+
def get_settings_defaults(self):
|
|
439
|
+
return {
|
|
440
|
+
"enabled": True,
|
|
441
|
+
"pin": "",
|
|
442
|
+
"url": "",
|
|
443
|
+
"camera_device": "",
|
|
444
|
+
"camera_resolution": "640x480",
|
|
445
|
+
"flip_horizontal": False,
|
|
446
|
+
"flip_vertical": False,
|
|
447
|
+
"brightness": 0,
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
def get_template_configs(self):
|
|
451
|
+
return [
|
|
452
|
+
{"type": "settings", "custom_bindings": False},
|
|
453
|
+
{"type": "navbar", "custom_bindings": False},
|
|
454
|
+
]
|
|
455
|
+
|
|
456
|
+
def get_template_vars(self):
|
|
457
|
+
return {"plugin_version": __plugin_version__}
|
|
458
|
+
|
|
459
|
+
def get_assets(self):
|
|
460
|
+
return {
|
|
461
|
+
"js": ["js/bitbang.js"],
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
def is_blueprint_csrf_protected(self):
|
|
465
|
+
return True
|
|
466
|
+
|
|
467
|
+
def is_template_autoescaped(self):
|
|
468
|
+
return True
|
|
469
|
+
|
|
470
|
+
__plugin_implementation__ = BitBangPlugin()
|
|
471
|
+
|
|
472
|
+
def __plugin_check__():
|
|
473
|
+
return True
|
|
474
|
+
|
|
475
|
+
def _get_update_information():
|
|
476
|
+
return {
|
|
477
|
+
"bitbang": {
|
|
478
|
+
"displayName": __plugin_name__,
|
|
479
|
+
"displayVersion": __plugin_version__,
|
|
480
|
+
"type": "github_release",
|
|
481
|
+
"user": "richlegrand",
|
|
482
|
+
"repo": "OctoPrint-BitBang",
|
|
483
|
+
"current": __plugin_version__,
|
|
484
|
+
"stable_branch": {
|
|
485
|
+
"name": "Stable",
|
|
486
|
+
"branch": "main",
|
|
487
|
+
"commitish": ["main"],
|
|
488
|
+
},
|
|
489
|
+
"pip": "https://github.com/richlegrand/OctoPrint-BitBang/archive/{target_version}.zip",
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
__plugin_hooks__ = {
|
|
494
|
+
"octoprint.plugin.softwareupdate.check_config": _get_update_information,
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
except ImportError:
|
|
498
|
+
# OctoPrint not installed - standalone CLI mode
|
|
499
|
+
pass
|
octoprint_bitbang/app.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""OctoPrint BitBang prototype - test app for video + HTTP tunnel.
|
|
2
|
+
|
|
3
|
+
Run with:
|
|
4
|
+
python -m octoprint_bitbang.app
|
|
5
|
+
python -m octoprint_bitbang.app --proxy localhost:5000
|
|
6
|
+
python -m octoprint_bitbang.app --proxy localhost:8080 --camera /dev/video2
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .octoprint_adapter import OctoPrintBitBang
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _make_test_app():
|
|
14
|
+
"""Create a simple ASGI test app serving the prototype HTML page."""
|
|
15
|
+
_dir = os.path.dirname(__file__)
|
|
16
|
+
|
|
17
|
+
async def test_app(scope, receive, send):
|
|
18
|
+
if scope["type"] != "http":
|
|
19
|
+
return
|
|
20
|
+
path = scope.get("path", "/")
|
|
21
|
+
|
|
22
|
+
if path == "/favicon.ico":
|
|
23
|
+
favicon_path = os.path.join(_dir, "static", "favicon.png")
|
|
24
|
+
try:
|
|
25
|
+
with open(favicon_path, "rb") as f:
|
|
26
|
+
body = f.read()
|
|
27
|
+
await send({"type": "http.response.start", "status": 200,
|
|
28
|
+
"headers": [(b"content-type", b"image/png")]})
|
|
29
|
+
await send({"type": "http.response.body", "body": body})
|
|
30
|
+
except FileNotFoundError:
|
|
31
|
+
await send({"type": "http.response.start", "status": 404, "headers": []})
|
|
32
|
+
await send({"type": "http.response.body", "body": b""})
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
# Serve index.html for everything else
|
|
36
|
+
html_path = os.path.join(_dir, "index.html")
|
|
37
|
+
with open(html_path, "rb") as f:
|
|
38
|
+
body = f.read()
|
|
39
|
+
await send({"type": "http.response.start", "status": 200,
|
|
40
|
+
"headers": [(b"content-type", b"text/html")]})
|
|
41
|
+
await send({"type": "http.response.body", "body": body})
|
|
42
|
+
|
|
43
|
+
return test_app
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main():
|
|
47
|
+
import argparse
|
|
48
|
+
from bitbang.adapter import add_bitbang_args, bitbang_kwargs
|
|
49
|
+
|
|
50
|
+
parser = argparse.ArgumentParser(description='OctoPrint via BitBang')
|
|
51
|
+
add_bitbang_args(parser)
|
|
52
|
+
parser.add_argument('--proxy',
|
|
53
|
+
help='Local server to proxy (e.g. localhost:5000)')
|
|
54
|
+
parser.add_argument('--camera',
|
|
55
|
+
help='Camera source override (e.g. /dev/video0)')
|
|
56
|
+
args = parser.parse_args()
|
|
57
|
+
|
|
58
|
+
ws_target = None
|
|
59
|
+
if args.proxy:
|
|
60
|
+
from bitbang.proxy import ReverseProxyASGI
|
|
61
|
+
asgi_app = ReverseProxyASGI(args.proxy)
|
|
62
|
+
ws_target = args.proxy
|
|
63
|
+
print(f"Proxying to {args.proxy}")
|
|
64
|
+
else:
|
|
65
|
+
asgi_app = _make_test_app()
|
|
66
|
+
|
|
67
|
+
camera_source = None
|
|
68
|
+
if args.camera:
|
|
69
|
+
camera_source = {
|
|
70
|
+
"type": "usb",
|
|
71
|
+
"device": args.camera,
|
|
72
|
+
"format": "v4l2",
|
|
73
|
+
"options": {"framerate": "30", "video_size": "640x480"},
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
adapter = OctoPrintBitBang(
|
|
77
|
+
asgi_app,
|
|
78
|
+
camera_source=camera_source,
|
|
79
|
+
ws_target=ws_target,
|
|
80
|
+
**bitbang_kwargs(args, program_name='octoprint'),
|
|
81
|
+
)
|
|
82
|
+
adapter.run()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == '__main__':
|
|
86
|
+
main()
|