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.
@@ -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
@@ -0,0 +1,2 @@
1
+ from octoprint_bitbang.app import main
2
+ main()
@@ -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()