Verzögerung eingebaut
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=ExoMy Video Delay Proxy
|
||||
After=network.target exomy-camera-stream.service
|
||||
Requires=exomy-camera-stream.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pi
|
||||
ExecStart=/usr/bin/python3 /home/pi/ExoMy_Software/scripts/video_delay_proxy.py
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -10,6 +10,7 @@ import time
|
||||
|
||||
PORT = 8082
|
||||
EXOMY_CONTAINER = 'exomy_autostart'
|
||||
DELAY_FILE = '/tmp/exomy_delay.txt'
|
||||
MOTOR_TEST_LOCK = threading.Lock()
|
||||
|
||||
|
||||
@@ -220,6 +221,33 @@ def collect_status():
|
||||
}
|
||||
|
||||
|
||||
def get_delay():
|
||||
try:
|
||||
with open(DELAY_FILE) as f:
|
||||
return max(0.0, min(10.0, float(f.read().strip())))
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def set_delay(seconds):
|
||||
seconds = max(0.0, min(10.0, float(seconds)))
|
||||
data = str(seconds).encode()
|
||||
try:
|
||||
fd = os.open(DELAY_FILE, os.O_WRONLY | os.O_TRUNC)
|
||||
except FileNotFoundError:
|
||||
fd = os.open(DELAY_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o666)
|
||||
try:
|
||||
os.write(fd, data)
|
||||
finally:
|
||||
os.close(fd)
|
||||
subprocess.run(
|
||||
['docker', 'exec', EXOMY_CONTAINER, 'bash', '-c',
|
||||
f'source /opt/ros/melodic/setup.bash && rosparam set /delay_seconds {seconds}'],
|
||||
capture_output=True, check=False
|
||||
)
|
||||
return seconds
|
||||
|
||||
|
||||
def restart_camera_service():
|
||||
subprocess.Popen(['systemctl', 'restart', 'exomy-camera-stream.service'])
|
||||
|
||||
@@ -449,6 +477,9 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == '/api/delay':
|
||||
self.write_json(200, {'delay_seconds': get_delay()})
|
||||
return
|
||||
if self.path == '/api/status':
|
||||
self.write_json(200, collect_status())
|
||||
return
|
||||
@@ -469,6 +500,24 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
self.write_json(404, {'status': 'Unbekannt'})
|
||||
|
||||
def do_POST(self):
|
||||
if self.path == '/api/delay':
|
||||
try:
|
||||
content_length = int(self.headers.get('Content-Length', '0'))
|
||||
except ValueError:
|
||||
content_length = 0
|
||||
raw_body = self.rfile.read(content_length) if content_length > 0 else b'{}'
|
||||
try:
|
||||
payload = json.loads(raw_body.decode('utf-8'))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
self.write_json(400, {'status': 'Ungültige Anfrage'})
|
||||
return
|
||||
try:
|
||||
seconds = set_delay(payload.get('delay_seconds', 0))
|
||||
self.write_json(200, {'status': 'Delay gesetzt', 'delay_seconds': seconds})
|
||||
except Exception as exc:
|
||||
self.write_json(500, {'status': str(exc)})
|
||||
return
|
||||
|
||||
if self.path == '/api/motor-test/drive-neutral/preview':
|
||||
try:
|
||||
content_length = int(self.headers.get('Content-Length', '0'))
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MJPEG Video Delay Proxy
|
||||
Puffert Frames vom Kamerastream (Port 8081) und liefert sie
|
||||
mit einstellbarer Verzögerung auf Port 8083.
|
||||
Delay wird aus /tmp/exomy_delay.txt gelesen (Sekunden als float).
|
||||
"""
|
||||
import collections
|
||||
import http.server
|
||||
import os
|
||||
import socketserver
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
DELAY_FILE = '/tmp/exomy_delay.txt'
|
||||
SOURCE_URL = 'http://localhost:8081/stream.mjpg'
|
||||
PORT = 8083
|
||||
BOUNDARY = b'--frame'
|
||||
|
||||
# Deque: (timestamp_float, jpeg_bytes)
|
||||
frame_buffer = collections.deque()
|
||||
buffer_lock = threading.Lock()
|
||||
latest_frame = None
|
||||
latest_lock = threading.Lock()
|
||||
|
||||
|
||||
def read_delay():
|
||||
try:
|
||||
with open(DELAY_FILE) as f:
|
||||
return max(0.0, float(f.read().strip()))
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def camera_reader():
|
||||
global latest_frame
|
||||
while True:
|
||||
try:
|
||||
req = urllib.request.urlopen(SOURCE_URL, timeout=5)
|
||||
buf = bytearray()
|
||||
while True:
|
||||
chunk = req.read(4096)
|
||||
if not chunk:
|
||||
break
|
||||
buf.extend(chunk)
|
||||
while True:
|
||||
start = buf.find(b'\xff\xd8')
|
||||
if start == -1:
|
||||
if len(buf) > 1024 * 1024:
|
||||
del buf[:-2]
|
||||
break
|
||||
end = buf.find(b'\xff\xd9', start + 2)
|
||||
if end == -1:
|
||||
if start > 0:
|
||||
del buf[:start]
|
||||
break
|
||||
jpeg = bytes(buf[start:end + 2])
|
||||
del buf[:end + 2]
|
||||
ts = time.monotonic()
|
||||
with buffer_lock:
|
||||
frame_buffer.append((ts, jpeg))
|
||||
# Puffer auf 12 Sekunden begrenzen
|
||||
cutoff = ts - 12.0
|
||||
while frame_buffer and frame_buffer[0][0] < cutoff:
|
||||
frame_buffer.popleft()
|
||||
with latest_lock:
|
||||
latest_frame = jpeg
|
||||
except Exception:
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def get_delayed_frame():
|
||||
delay = read_delay()
|
||||
if delay <= 0:
|
||||
with latest_lock:
|
||||
return latest_frame
|
||||
target_ts = time.monotonic() - delay
|
||||
with buffer_lock:
|
||||
if not frame_buffer:
|
||||
return None
|
||||
best = frame_buffer[0][1]
|
||||
for ts, jpeg in frame_buffer:
|
||||
if ts <= target_ts:
|
||||
best = jpeg
|
||||
else:
|
||||
break
|
||||
return best
|
||||
|
||||
|
||||
class ReusableTCPServer(socketserver.ThreadingTCPServer):
|
||||
allow_reuse_address = True
|
||||
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path != '/stream.mjpg':
|
||||
self.send_error(404)
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header('Age', '0')
|
||||
self.send_header('Cache-Control', 'no-cache, private')
|
||||
self.send_header('Pragma', 'no-cache')
|
||||
self.send_header('Content-Type', 'multipart/x-mixed-replace; boundary=frame')
|
||||
self.end_headers()
|
||||
fps = 10
|
||||
while True:
|
||||
frame = get_delayed_frame()
|
||||
if frame is None:
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
try:
|
||||
self.wfile.write(BOUNDARY + b'\r\n')
|
||||
self.wfile.write(b'Content-Type: image/jpeg\r\n')
|
||||
self.wfile.write(f'Content-Length: {len(frame)}\r\n\r\n'.encode())
|
||||
self.wfile.write(frame)
|
||||
self.wfile.write(b'\r\n')
|
||||
self.wfile.flush()
|
||||
time.sleep(1.0 / fps)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
break
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
return
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Delay-Datei initialisieren (world-writable damit Admin-API schreiben kann)
|
||||
try:
|
||||
fd = os.open(DELAY_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o666)
|
||||
os.write(fd, b'0.0')
|
||||
os.close(fd)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=camera_reader, daemon=True).start()
|
||||
with ReusableTCPServer(('0.0.0.0', PORT), Handler) as server:
|
||||
server.daemon_threads = True
|
||||
print(f'Video-Delay-Proxy läuft auf Port {PORT}')
|
||||
server.serve_forever()
|
||||
Reference in New Issue
Block a user