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,117 @@
1
+ """Camera auto-detection for OctoPrint BitBang.
2
+
3
+ Detects available camera sources in priority order:
4
+ 1. picamera2 CSI camera (Pi hardware, software encode in aiortc)
5
+ 2. USB webcam via V4L2/dshow/avfoundation (software encode)
6
+ 3. None (HTTP-only mode, no crash)
7
+ """
8
+
9
+ import sys
10
+
11
+
12
+ def detect_camera(logger=None):
13
+ """Detect best available camera source.
14
+
15
+ Returns dict with keys: type, device, format, options
16
+ Or None if no camera found.
17
+ """
18
+ log = logger.info if logger else lambda msg: print(f"[camera] {msg}")
19
+
20
+ # 1. picamera2 (Raspberry Pi CSI)
21
+ source = _try_picamera2()
22
+ if source:
23
+ log("Found Pi CSI camera via picamera2")
24
+ return source
25
+
26
+ # 2. USB webcam (platform-specific)
27
+ source = _try_usb_webcam()
28
+ if source:
29
+ log(f"Found USB webcam: {source['device']}")
30
+ return source
31
+
32
+ log("No camera found - running in HTTP-only mode")
33
+ return None
34
+
35
+
36
+ def _try_picamera2():
37
+ """Check if picamera2 is available (Raspberry Pi CSI camera)."""
38
+ try:
39
+ from picamera2 import Picamera2
40
+ cam = Picamera2()
41
+ cam.close()
42
+ return {"type": "picamera2"}
43
+ except Exception:
44
+ return None
45
+
46
+
47
+ def _try_usb_webcam():
48
+ """Check for USB webcam (platform-specific)."""
49
+ if sys.platform == 'darwin':
50
+ return {
51
+ "type": "usb",
52
+ "device": "0:none",
53
+ "format": "avfoundation",
54
+ "options": {"framerate": "30", "video_size": "640x480"},
55
+ }
56
+ elif sys.platform == 'win32':
57
+ camera = _find_windows_camera()
58
+ if camera:
59
+ return {
60
+ "type": "usb",
61
+ "device": f"video={camera}",
62
+ "format": "dshow",
63
+ "options": {"framerate": "30", "video_size": "640x480"},
64
+ }
65
+ return None
66
+ else:
67
+ # Linux: check /dev/video0 through /dev/video3
68
+ import os
69
+ for i in range(4):
70
+ dev = f"/dev/video{i}"
71
+ if os.path.exists(dev):
72
+ if _is_v4l2_capture(dev):
73
+ return {
74
+ "type": "usb",
75
+ "device": dev,
76
+ "format": "v4l2",
77
+ "options": {"framerate": "30", "video_size": "640x480"},
78
+ }
79
+ return None
80
+
81
+
82
+ def _is_v4l2_capture(device):
83
+ """Check if a V4L2 device is a video capture device (not encoder/decoder).
84
+
85
+ Skips V4L2 M2M devices like /dev/video10, /dev/video11 on Raspberry Pi.
86
+ """
87
+ try:
88
+ import fcntl
89
+ import struct
90
+ VIDIOC_QUERYCAP = 0x80685600
91
+ with open(device, 'rb') as f:
92
+ buf = bytearray(104)
93
+ fcntl.ioctl(f, VIDIOC_QUERYCAP, buf)
94
+ capabilities = struct.unpack_from('<I', buf, 84)[0]
95
+ V4L2_CAP_VIDEO_CAPTURE = 0x00000001
96
+ return bool(capabilities & V4L2_CAP_VIDEO_CAPTURE)
97
+ except Exception:
98
+ # If we can't probe, assume it's a capture device
99
+ return True
100
+
101
+
102
+ def _find_windows_camera():
103
+ """Discover first available camera on Windows via PowerShell."""
104
+ import subprocess
105
+ try:
106
+ result = subprocess.run(
107
+ ['powershell', '-Command',
108
+ 'Get-PnpDevice -Class Camera -Status OK | '
109
+ 'Select-Object -ExpandProperty FriendlyName'],
110
+ capture_output=True, text=True, timeout=5
111
+ )
112
+ name = result.stdout.strip().split('\n')[0].strip()
113
+ if name:
114
+ return name
115
+ except Exception:
116
+ pass
117
+ return None
@@ -0,0 +1,59 @@
1
+ """MediaStreamTrack wrapper that hflip/vflips frames via PyAV.
2
+
3
+ Used for USB webcams where flip can't be done at the sensor level.
4
+ aiortc's MediaPlayer already decodes frames to av.VideoFrame before
5
+ handing them to the H.264 encoder, so we intercept there and flip
6
+ in-place.
7
+ """
8
+
9
+ from aiortc import MediaStreamTrack
10
+
11
+
12
+ class FlippedTrack(MediaStreamTrack):
13
+ kind = "video"
14
+
15
+ def __init__(self, source, hflip=False, vflip=False):
16
+ super().__init__()
17
+ self._source = source
18
+ self._hflip = bool(hflip)
19
+ self._vflip = bool(vflip)
20
+ self._graph = None
21
+ self._buffer_src = None
22
+ self._buffer_sink = None
23
+
24
+ def _init_graph(self, frame):
25
+ # Build a PyAV filter graph matching the first frame's format.
26
+ # Chaining hflip and vflip gives us a 180° rotation when both are set.
27
+ from av.filter import Graph
28
+
29
+ graph = Graph()
30
+ src = graph.add_buffer(template=frame)
31
+ last = src
32
+ if self._hflip:
33
+ n = graph.add("hflip")
34
+ last.link_to(n)
35
+ last = n
36
+ if self._vflip:
37
+ n = graph.add("vflip")
38
+ last.link_to(n)
39
+ last = n
40
+ sink = graph.add("buffersink")
41
+ last.link_to(sink)
42
+ graph.configure()
43
+ self._graph = graph
44
+ self._buffer_src = src
45
+ self._buffer_sink = sink
46
+
47
+ async def recv(self):
48
+ frame = await self._source.recv()
49
+ if not (self._hflip or self._vflip):
50
+ return frame
51
+ if self._graph is None:
52
+ self._init_graph(frame)
53
+ self._buffer_src.push(frame)
54
+ return self._buffer_sink.pull()
55
+
56
+ def stop(self):
57
+ super().stop()
58
+ if self._source and hasattr(self._source, "stop"):
59
+ self._source.stop()
@@ -0,0 +1,16 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <style>
5
+ body { font-family: sans-serif; text-align: center; padding: 20px; }
6
+ video { width: 100%; max-width: 640px; background: #000; }
7
+ #status { color: #666; margin-top: 10px; }
8
+ </style>
9
+ </head>
10
+ <body>
11
+ <h2>OctoPrint BitBang Prototype</h2>
12
+ <video data-bitbang-stream="camera" autoplay playsinline muted></video>
13
+ <p id="status">Camera stream + HTTP tunnel active</p>
14
+ <p>In the real plugin, OctoPrint's full UI loads here via the BitBang HTTP tunnel.</p>
15
+ </body>
16
+ </html>
@@ -0,0 +1,103 @@
1
+ """OctoPrint BitBang adapter - extends BitBangASGI with camera video track.
2
+
3
+ Subclasses BitBangASGI to add a camera video track alongside async HTTP
4
+ reverse proxy. Fully async -- no WSGI thread pool.
5
+ Camera source is auto-detected or explicitly configured.
6
+ """
7
+
8
+ from bitbang import BitBangASGI
9
+ from aiortc.contrib.media import MediaRelay
10
+
11
+ from .camera import detect_camera
12
+
13
+
14
+ def force_h264(pc, sender):
15
+ """Force H.264 codec on a transceiver so aiortc doesn't negotiate VP8."""
16
+ from aiortc.rtcrtpsender import RTCRtpSender
17
+ h264 = [c for c in RTCRtpSender.getCapabilities("video").codecs
18
+ if c.name == "H264"]
19
+ for t in pc.getTransceivers():
20
+ if t.sender is sender:
21
+ t.setCodecPreferences(h264)
22
+ break
23
+
24
+
25
+ class OctoPrintBitBang(BitBangASGI):
26
+ """BitBang adapter with camera video for OctoPrint remote access.
27
+
28
+ Extends BitBangASGI to capture video from the best available camera
29
+ source and share it with all connected clients using MediaRelay.
30
+ Falls back to HTTP-only mode if no camera is found.
31
+ """
32
+
33
+ def __init__(self, app, camera_source=None, ws_target=None, **kwargs):
34
+ super().__init__(app, **kwargs)
35
+ self.ws_target = ws_target # host:port for WebSocket bridging
36
+ self.relay = MediaRelay()
37
+ self.player = None
38
+ self._init_camera(camera_source)
39
+
40
+ def _init_camera(self, camera_source):
41
+ """Initialize camera from explicit source or auto-detect."""
42
+ source = camera_source or detect_camera()
43
+ if not source:
44
+ print("No camera - running in HTTP-only mode")
45
+ return
46
+
47
+ if source["type"] == "usb":
48
+ # USB webcam (software H.264 encode via aiortc)
49
+ try:
50
+ from .usb_camera_source import UsbCameraSource
51
+ self.player = UsbCameraSource(
52
+ device=source["device"],
53
+ format=source.get("format"),
54
+ options=source.get("options", {}),
55
+ brightness=source.get("brightness", 0),
56
+ flip_horizontal=source.get("flip_horizontal", False),
57
+ flip_vertical=source.get("flip_vertical", False),
58
+ )
59
+ print(f"Opened USB camera: {source['device']}")
60
+ except Exception as e:
61
+ print(f"Warning: Could not open camera '{source['device']}': {e}")
62
+
63
+ elif source["type"] == "picamera2":
64
+ # Pi CSI camera - picamera2 H264Encoder (hw on Pi 4, sw on Pi 5)
65
+ # emits Annex-B H.264 that aiortc packetizes without re-encoding.
66
+ try:
67
+ from .pi_h264_source import PiH264Track
68
+ size = source.get("size", (640, 480))
69
+ framerate = source.get("framerate", 30)
70
+ bitrate = source.get("bitrate", 4_000_000)
71
+ brightness = source.get("brightness", 0)
72
+ self.player = PiH264Track(
73
+ size=size, framerate=framerate, bitrate=bitrate,
74
+ brightness=brightness,
75
+ flip_horizontal=source.get("flip_horizontal", False),
76
+ flip_vertical=source.get("flip_vertical", False),
77
+ )
78
+ print(f"Opened Pi CSI camera via H264Encoder ({size[0]}x{size[1]}@{framerate})")
79
+ except Exception as e:
80
+ print(f"Warning: Could not open Pi CSI camera: {e}")
81
+
82
+ def setup_peer_connection(self, pc, client_id):
83
+ """Add camera video track to peer connection."""
84
+ if self.player and self.player.video:
85
+ sender = pc.addTrack(self.relay.subscribe(self.player.video))
86
+ force_h264(pc, sender)
87
+ print(f"Added camera video track for {client_id}")
88
+
89
+ def get_stream_metadata(self):
90
+ """Return stream name for video track."""
91
+ if self.player and self.player.video:
92
+ return {"0": "camera"}
93
+ return {}
94
+
95
+ async def close(self):
96
+ """Close peer connections and media player."""
97
+ await super().close()
98
+ if self.player:
99
+ if hasattr(self.player, "stop"):
100
+ self.player.stop()
101
+ elif self.player.video:
102
+ self.player.video.stop()
103
+ self.player = None
@@ -0,0 +1,48 @@
1
+ """aiortc video track backed by picamera2 (Raspberry Pi CSI camera).
2
+
3
+ Captures frames from picamera2 and yields them as aiortc VideoFrames.
4
+ aiortc handles software H.264 encoding downstream.
5
+ """
6
+
7
+ import asyncio
8
+ import fractions
9
+
10
+ from aiortc import MediaStreamTrack
11
+ from av import VideoFrame
12
+
13
+
14
+ class PiCameraTrack(MediaStreamTrack):
15
+ kind = "video"
16
+
17
+ def __init__(self, size=(640, 480), framerate=30):
18
+ super().__init__()
19
+ from picamera2 import Picamera2
20
+
21
+ self.picam2 = Picamera2()
22
+ config = self.picam2.create_video_configuration(
23
+ main={"size": size, "format": "RGB888"}
24
+ )
25
+ self.picam2.configure(config)
26
+ self.picam2.start()
27
+
28
+ self.framerate = framerate
29
+ self._timestamp = 0
30
+ self._time_base = fractions.Fraction(1, 90000)
31
+ self._ticks_per_frame = int(90000 / framerate)
32
+
33
+ async def recv(self):
34
+ loop = asyncio.get_event_loop()
35
+ array = await loop.run_in_executor(None, self.picam2.capture_array)
36
+ frame = VideoFrame.from_ndarray(array, format="bgr24")
37
+ frame.pts = self._timestamp
38
+ frame.time_base = self._time_base
39
+ self._timestamp += self._ticks_per_frame
40
+ return frame
41
+
42
+ def stop(self):
43
+ super().stop()
44
+ try:
45
+ self.picam2.stop()
46
+ self.picam2.close()
47
+ except Exception:
48
+ pass
@@ -0,0 +1,149 @@
1
+ """picamera2 H264Encoder → aiortc MediaStreamTrack passthrough.
2
+
3
+ Hardware H.264 on Pi 4 (V4L2 M2M at /dev/video11), software (LibavH264Encoder)
4
+ on Pi 5. picamera2 delivers Annex-B NAL units plus a microsecond timestamp
5
+ via Output.outputframe(); we wrap each as an av.Packet with pts set, so
6
+ aiortc's H264Payloader.pack() packetizes into RTP without re-encoding.
7
+ """
8
+
9
+ import asyncio
10
+ import io
11
+ from fractions import Fraction
12
+
13
+ import av
14
+ from aiortc import MediaStreamTrack
15
+ from picamera2.outputs import Output
16
+
17
+ # picamera2's V4L2Encoder reports timestamps as integer microseconds
18
+ # (seconds * 1_000_000 + microseconds from V4L2 buffer timestamp).
19
+ _TIME_BASE = Fraction(1, 1_000_000)
20
+
21
+
22
+ class _QueueOutput(Output):
23
+ """picamera2 Output that turns each encoded frame into an av.Packet
24
+ and enqueues it on a provided asyncio.Queue (thread-safely)."""
25
+
26
+ def __init__(self, loop, queue):
27
+ super().__init__()
28
+ self._loop = loop
29
+ self._queue = queue
30
+
31
+ def outputframe(self, frame, keyframe=True, timestamp=None, packet=None, audio=False):
32
+ if timestamp is None:
33
+ return
34
+ data = frame if isinstance(frame, (bytes, bytearray)) else bytes(frame)
35
+ pkt = av.Packet(data)
36
+ pkt.pts = timestamp
37
+ pkt.dts = timestamp
38
+ pkt.time_base = _TIME_BASE
39
+ pkt.is_keyframe = keyframe
40
+ self._loop.call_soon_threadsafe(self._try_put, pkt)
41
+
42
+ def _try_put(self, pkt):
43
+ # Drop oldest on overflow so live stream doesn't stall.
44
+ if self._queue.full():
45
+ try:
46
+ self._queue.get_nowait()
47
+ except asyncio.QueueEmpty:
48
+ pass
49
+ try:
50
+ self._queue.put_nowait(pkt)
51
+ except asyncio.QueueFull:
52
+ pass
53
+
54
+
55
+ class PiH264Track(MediaStreamTrack):
56
+ """aiortc video track backed by picamera2's H264Encoder."""
57
+
58
+ kind = "video"
59
+
60
+ def __init__(self, size=(640, 480), framerate=30, bitrate=1_500_000,
61
+ brightness=0, flip_horizontal=False, flip_vertical=False):
62
+ super().__init__()
63
+ from picamera2 import Picamera2
64
+ from picamera2.encoders import H264Encoder
65
+ from libcamera import Transform
66
+
67
+ self._size = size
68
+ self._framerate = framerate
69
+ self._brightness = brightness # -100..100, scaled to picamera2's -1.0..1.0
70
+
71
+ self.picam2 = Picamera2()
72
+ config = self.picam2.create_video_configuration(
73
+ main={"size": size, "format": "YUV420"},
74
+ transform=Transform(hflip=bool(flip_horizontal), vflip=bool(flip_vertical)),
75
+ controls={
76
+ "FrameRate": float(framerate),
77
+ "Brightness": self._brightness / 100.0,
78
+ },
79
+ )
80
+ self.picam2.configure(config)
81
+ # Start the camera up front (no encoder yet) so capture_image works
82
+ # before any WebRTC client connects — needed for timelapse snapshots.
83
+ self.picam2.start()
84
+
85
+ # Let the encoder pick its own GOP length (V4L2 default is ~60
86
+ # frames on Pi 4). repeat=True → SPS/PPS before each IDR so late-
87
+ # joining WebRTC subscribers can start decoding when one arrives.
88
+ self.encoder = H264Encoder(
89
+ bitrate=bitrate,
90
+ repeat=True,
91
+ profile="baseline",
92
+ )
93
+
94
+ self._encoder_started = False
95
+ self._queue: asyncio.Queue | None = None
96
+ self._output: _QueueOutput | None = None
97
+
98
+ def _ensure_started(self):
99
+ if self._encoder_started:
100
+ return
101
+ self._queue = asyncio.Queue(maxsize=30)
102
+ self._output = _QueueOutput(asyncio.get_running_loop(), self._queue)
103
+ self.picam2.start_encoder(self.encoder, self._output)
104
+ self._encoder_started = True
105
+
106
+ async def recv(self):
107
+ self._ensure_started()
108
+ return await self._queue.get()
109
+
110
+ def set_brightness(self, value):
111
+ """Update brightness live. `value` is -100..100 (mapped to -1.0..1.0)."""
112
+ self._brightness = max(-100, min(100, int(value)))
113
+ self.picam2.set_controls({"Brightness": self._brightness / 100.0})
114
+
115
+ def capture_snapshot(self):
116
+ """Return a JPEG snapshot grabbed straight from the camera. Safe to
117
+ call concurrently with the H.264 encoder — picamera2/libcamera
118
+ delivers a separate frame to the application without disturbing
119
+ the encoder pipeline. Uses request.save's YUV420→JPEG fast path
120
+ (simplejpeg.encode_jpeg_yuv_planes) so we never go through PIL,
121
+ which doesn't support a YUV420 mode."""
122
+ buf = io.BytesIO()
123
+ request = self.picam2.capture_request()
124
+ try:
125
+ request.save("main", buf, format="jpeg")
126
+ finally:
127
+ request.release()
128
+ return buf.getvalue()
129
+
130
+ @property
131
+ def video(self):
132
+ # MediaPlayer-shaped interface so the adapter can treat us like one.
133
+ return self
134
+
135
+ def stop(self):
136
+ super().stop()
137
+ try:
138
+ if self._encoder_started:
139
+ self.picam2.stop_encoder()
140
+ except Exception:
141
+ pass
142
+ try:
143
+ self.picam2.stop()
144
+ except Exception:
145
+ pass
146
+ try:
147
+ self.picam2.close()
148
+ except Exception:
149
+ pass
Binary file