145 lines
3.7 KiB
Python
145 lines
3.7 KiB
Python
#!/usr/bin/env python3
|
|
"""On-demand camera feed for v4l2loopback.
|
|
|
|
Holds /dev/video50 open permanently so v4l2loopback stays in capture mode.
|
|
Writes black frames when idle (0% CPU, LED off). Starts the real camera
|
|
pipeline when an app opens the device, relaying frames through the same fd.
|
|
Stops the camera when all readers close. No fd gap = no stream interruption.
|
|
"""
|
|
|
|
import fcntl, os, signal, struct, subprocess, sys, threading, time
|
|
|
|
DEVICE = "/dev/video50"
|
|
W, H = 1920, 1080
|
|
FSIZE = W * H * 3 // 2
|
|
POLL = 2
|
|
STOP_DELAY = 6
|
|
|
|
BLACK = bytes(W * H) + bytes([128] * (W * H // 2))
|
|
|
|
dev = os.open(DEVICE, os.O_WRONLY)
|
|
pixfmt = struct.pack("II4sIIII4x188x", W, H, b"NV12", 1, W, FSIZE, 0)
|
|
fmt = struct.pack("I4x", 2) + pixfmt
|
|
fcntl.ioctl(dev, 0xC0D05605, fmt)
|
|
os.write(dev, BLACK)
|
|
|
|
# Restart WirePlumber so it sees the capture device
|
|
time.sleep(1)
|
|
subprocess.run(["systemctl", "--user", "restart", "wireplumber.service"],
|
|
capture_output=True, timeout=10)
|
|
|
|
cam_proc = None
|
|
cam_lock = threading.Lock()
|
|
stop_idle = threading.Event()
|
|
|
|
|
|
def write_idle():
|
|
while not stop_idle.is_set():
|
|
try:
|
|
os.write(dev, BLACK)
|
|
except OSError:
|
|
pass
|
|
stop_idle.wait(2)
|
|
|
|
|
|
def relay_camera():
|
|
global cam_proc
|
|
env = os.environ.copy()
|
|
env["GST_PLUGIN_PATH"] = "/usr/lib/gstreamer-1.0"
|
|
cam_proc = subprocess.Popen(
|
|
["gst-launch-1.0", "-q",
|
|
"icamerasrc", "device-name=ov08x40-uf",
|
|
"sharpness=80", "ev=-1", "saturation=10",
|
|
"!", "video/x-raw,format=NV12,width=1920,height=1080",
|
|
"!", "videoflip", "method=rotate-180",
|
|
"!", "fdsink", "fd=1"],
|
|
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env,
|
|
)
|
|
buf = b""
|
|
while cam_proc.poll() is None:
|
|
data = cam_proc.stdout.read(65536)
|
|
if not data:
|
|
break
|
|
buf += data
|
|
while len(buf) >= FSIZE:
|
|
try:
|
|
os.write(dev, buf[:FSIZE])
|
|
except OSError:
|
|
pass
|
|
buf = buf[FSIZE:]
|
|
cam_proc = None
|
|
|
|
|
|
def has_readers():
|
|
try:
|
|
r = subprocess.run(["fuser", DEVICE], capture_output=True, text=True, timeout=5)
|
|
for pid in r.stdout.strip().split():
|
|
pid = pid.strip()
|
|
if not pid:
|
|
continue
|
|
if int(pid) == os.getpid():
|
|
continue
|
|
with cam_lock:
|
|
if cam_proc and int(pid) == cam_proc.pid:
|
|
continue
|
|
return True
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
|
|
def start_camera():
|
|
stop_idle.set()
|
|
t = threading.Thread(target=relay_camera, daemon=True)
|
|
t.start()
|
|
return t
|
|
|
|
|
|
def stop_camera():
|
|
with cam_lock:
|
|
if cam_proc:
|
|
cam_proc.terminate()
|
|
try:
|
|
cam_proc.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
cam_proc.kill()
|
|
stop_idle.clear()
|
|
t = threading.Thread(target=write_idle, daemon=True)
|
|
t.start()
|
|
return t
|
|
|
|
|
|
def shutdown(sig, frame):
|
|
stop_idle.set()
|
|
with cam_lock:
|
|
if cam_proc:
|
|
cam_proc.terminate()
|
|
os.close(dev)
|
|
sys.exit(0)
|
|
|
|
|
|
signal.signal(signal.SIGTERM, shutdown)
|
|
signal.signal(signal.SIGINT, shutdown)
|
|
|
|
# Start idle
|
|
idle_thread = threading.Thread(target=write_idle, daemon=True)
|
|
idle_thread.start()
|
|
|
|
active = False
|
|
idle_count = 0
|
|
|
|
while True:
|
|
if has_readers():
|
|
idle_count = 0
|
|
if not active:
|
|
start_camera()
|
|
active = True
|
|
else:
|
|
if active:
|
|
idle_count += 1
|
|
if idle_count >= STOP_DELAY // POLL:
|
|
stop_camera()
|
|
active = False
|
|
idle_count = 0
|
|
time.sleep(POLL)
|