Files
wireclaw-pizero/camera_server.py
nicoboy cdd3fa6965 fix: sideband UDP — socket AF_INET6 automatique si JETSON_HOST est IPv6
Le socket était créé en AF_INET, bloquant tout envoi vers une adresse
Mycelium IPv6. _udp_sock() détecte le type d'adresse via ipaddress et
choisit AF_INET6 (4-tuple) ou AF_INET selon JETSON_HOST.
Gère aussi le changement de host à chaud via POST /settings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 16:49:23 +02:00

365 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
WireClaw — Pi Zero 2W camera server
Flask API port 5000 : /health /settings
RTP H264 → Jetson via GStreamer subprocess
Sideband JSON UDP port 5602 → Jetson (1 Hz)
Encoder strategy: v4l2h264enc hardware (default) → x264enc software (auto-fallback)
Config: .env (base) overridden by .env.dev if present
"""
import ipaddress
import json
import os
import shlex
import signal
import socket
import subprocess
import threading
import time
import logging
from flask import Flask, jsonify, request
# --- .env loading -------------------------------------------------------------
def _load_env(path: str) -> None:
if not os.path.exists(path):
return
with open(path) as f:
for raw in f:
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
os.environ[key.strip()] = val.strip()
_load_env(".env")
_load_env(".env.dev") # overrides .env when present
# --- Configuration -----------------------------------------------------------
DEFAULTS: dict = {
"jetson_host": os.environ.get("JETSON_HOST", "192.168.1.84"),
"jetson_port": int(os.environ.get("JETSON_PORT", "5600")),
"width": int(os.environ.get("WIDTH", "640")),
"height": int(os.environ.get("HEIGHT", "480")),
"fps": int(os.environ.get("FPS", "15")),
"bitrate_kbps": int(os.environ.get("BITRATE_KBPS", "1000")),
# "auto" tries hw then sw; "hw" forces v4l2h264enc; "sw" forces x264enc
"encoder": os.environ.get("ENCODER", "auto"),
}
FLASK_PORT = 5000
SIDEBAND_PORT = 5602
START_TIME = time.time()
# --- Logging -----------------------------------------------------------------
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
log = logging.getLogger(__name__)
# --- Helpers -----------------------------------------------------------------
def _get_temp_celsius() -> float | None:
try:
out = subprocess.check_output(["vcgencmd", "measure_temp"], text=True, timeout=1)
return float(out.strip().split("=")[1].replace("'C", ""))
except Exception:
return None
def _get_throttled() -> bool:
try:
out = subprocess.check_output(["vcgencmd", "get_throttled"], text=True, timeout=1)
# output: "throttled=0x0"
return out.strip().split("=")[1] != "0x0"
except Exception:
return False
def _get_cpu_pct() -> float | None:
"""Read /proc/stat twice 0.5 s apart, return CPU busy %."""
def _read() -> list[int] | None:
try:
with open("/proc/stat") as f:
return [int(x) for x in f.readline().split()[1:]]
except Exception:
return None
s1 = _read()
time.sleep(0.5)
s2 = _read()
if s1 is None or s2 is None:
return None
d_total = sum(s2) - sum(s1)
d_idle = (s2[3] + s2[4]) - (s1[3] + s1[4]) # idle + iowait
return round(100.0 * (1 - d_idle / d_total), 1) if d_total else 0.0
def _h264_level(w: int, h: int) -> str:
if w * h > 1920 * 1080:
return "5"
elif w * h > 1280 * 720:
return "4"
else:
return "3.2"
# --- Pipeline builder --------------------------------------------------------
def _build_pipeline(cfg: dict, encoder: str) -> str:
w, h, fps = cfg["width"], cfg["height"], cfg["fps"]
host, port = cfg["jetson_host"], cfg["jetson_port"]
level = _h264_level(w, h)
sink = f"rtph264pay config-interval=1 pt=96 ! queue ! udpsink host={host} port={port}"
if encoder == "hw":
return (
f"libcamerasrc ! "
f"video/x-raw,width={w},height={h},format=NV12,framerate={fps}/1 ! "
f"v4l2convert ! "
f"v4l2h264enc ! "
f"video/x-h264,level=(string){level} ! "
f"h264parse ! "
f"{sink}"
)
return (
f"libcamerasrc ! "
f"video/x-raw,width={w},height={h},framerate={fps}/1 ! "
f"videoconvert ! "
f"x264enc tune=zerolatency bitrate={cfg['bitrate_kbps']} ! "
f"{sink}"
)
# --- Sideband UDP thread ------------------------------------------------------
def _udp_sock(host: str) -> tuple:
"""Return (socket, is_ipv6) with the right address family for host."""
try:
is_ipv6 = isinstance(ipaddress.ip_address(host), ipaddress.IPv6Address)
except ValueError:
is_ipv6 = False
family = socket.AF_INET6 if is_ipv6 else socket.AF_INET
return socket.socket(family, socket.SOCK_DGRAM), is_ipv6
def _sideband_worker(gst: "GstManager") -> None:
init_cfg = gst.get_cfg()
sock, is_ipv6 = _udp_sock(init_cfg["jetson_host"])
current_host = init_cfg["jetson_host"]
log.info("Sideband UDP ready (→ %s port %d)", "IPv6" if is_ipv6 else "IPv4", SIDEBAND_PORT)
try:
while not gst._shutdown.is_set():
cfg = gst.get_cfg()
host = cfg["jetson_host"]
if host != current_host:
sock.close()
sock, is_ipv6 = _udp_sock(host)
current_host = host
log.info("Sideband socket recreated for new host %s (%s)", host, "IPv6" if is_ipv6 else "IPv4")
cpu_pct = _get_cpu_pct() # blocks 0.5 s internally
temp = _get_temp_celsius()
throttled = _get_throttled()
payload = json.dumps({
"ts": time.time(),
"width": cfg["width"],
"height": cfg["height"],
"fps": cfg["fps"],
"encoder": gst.encoder_active or "unknown",
"bitrate_kbps": cfg["bitrate_kbps"],
"temp_c": temp,
"cpu_pct": cpu_pct,
"throttled": throttled,
"uptime_s": int(time.time() - START_TIME),
}).encode()
addr = (host, SIDEBAND_PORT, 0, 0) if is_ipv6 else (host, SIDEBAND_PORT)
try:
sock.sendto(payload, addr)
except OSError as exc:
log.debug("sideband send: %s", exc)
time.sleep(0.5) # 0.5 s cpu read + 0.5 s sleep ≈ 1 Hz
finally:
sock.close()
# --- GStreamer process manager -----------------------------------------------
class GstManager:
def __init__(self) -> None:
self._cfg: dict = dict(DEFAULTS)
self._lock = threading.Lock()
self._proc: subprocess.Popen | None = None
self._shutdown = threading.Event()
self._reset_encoder = threading.Event()
self._thread: threading.Thread | None = None
self.encoder_active: str | None = None
# Public API
def start(self) -> None:
self._thread = threading.Thread(target=self._worker, name="gst-rtp", daemon=True)
self._thread.start()
threading.Thread(target=_sideband_worker, args=(self,), name="sideband", daemon=True).start()
def stop(self) -> None:
self._shutdown.set()
self._terminate()
def restart_with(self, patch: dict) -> None:
with self._lock:
self._cfg.update(patch)
self._reset_encoder.set()
self._terminate()
def is_alive(self) -> bool:
with self._lock:
return self._proc is not None and self._proc.poll() is None
def get_cfg(self) -> dict:
with self._lock:
return dict(self._cfg)
# Internal
def _terminate(self) -> None:
with self._lock:
proc = self._proc
if proc and proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=3)
except subprocess.TimeoutExpired:
proc.kill()
def _launch(self, encoder: str) -> "subprocess.Popen | None":
with self._lock:
cfg = dict(self._cfg)
pipeline = _build_pipeline(cfg, encoder)
cmd = ["gst-launch-1.0", "-e"] + shlex.split(pipeline)
log.info("GStreamer [%s] pipeline: %s", encoder, pipeline)
try:
return subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
except FileNotFoundError:
log.error("gst-launch-1.0 not found in PATH")
return None
@staticmethod
def _drain_stderr(proc: "subprocess.Popen") -> None:
for line in proc.stderr:
stripped = line.rstrip()
if stripped:
log.debug("gst: %s", stripped)
def _worker(self) -> None:
def _make_queue() -> tuple[list[str], str]:
with self._lock:
p = self._cfg.get("encoder", "auto")
if p == "hw":
return ["hw"], p
if p == "sw":
return ["sw"], p
return ["hw", "sw"], p
queue, pref = _make_queue()
while not self._shutdown.is_set():
if self._reset_encoder.is_set():
self._reset_encoder.clear()
queue, pref = _make_queue()
log.info("Encoder queue reset → %s", queue)
for enc in queue:
proc = self._launch(enc)
if proc is None:
continue
with self._lock:
self._proc = proc
self.encoder_active = enc
log.info("RTP stream running (encoder=%s)", enc)
threading.Thread(target=self._drain_stderr, args=(proc,), daemon=True).start()
proc.wait()
rc = proc.returncode
if self._shutdown.is_set():
return
log.warning("GStreamer [%s] exited rc=%d", enc, rc)
if enc == "hw" and pref == "auto":
log.warning("Hardware encoder failed — fallback to x264enc")
queue = ["sw"]
break # restart outer while-loop with updated queue
if not self._shutdown.is_set():
log.info("Restarting in 3 s…")
time.sleep(3)
_gst = GstManager()
# --- Flask app ---------------------------------------------------------------
app = Flask(__name__)
@app.route("/health")
def health():
alive = _gst.is_alive()
return jsonify({
"status": "ok" if alive else "degraded",
"rtp": alive,
"encoder": _gst.encoder_active,
})
@app.route("/settings", methods=["GET"])
def settings_get():
return jsonify(_gst.get_cfg())
@app.route("/settings", methods=["POST"])
def settings_post():
data = request.get_json(force=True, silent=True) or {}
allowed = {"jetson_host", "jetson_port", "width", "height", "fps", "bitrate_kbps", "encoder"}
patch = {k: v for k, v in data.items() if k in allowed}
if not patch:
return jsonify({"error": "no valid fields", "allowed": sorted(allowed)}), 400
if patch.get("width", 0) > 2592 or patch.get("height", 0) > 1944:
return jsonify({"error": "resolution exceeds OV5647 max (2592×1944)"}), 400
_gst.restart_with(patch)
return jsonify({"status": "restarting", "applied": patch})
# --- Shutdown ----------------------------------------------------------------
def _on_signal(sig, _frame) -> None:
log.info("Signal %d — shutting down", sig)
_gst.stop()
os._exit(0)
# --- Entrypoint --------------------------------------------------------------
if __name__ == "__main__":
signal.signal(signal.SIGTERM, _on_signal)
signal.signal(signal.SIGINT, _on_signal)
_gst.start()
time.sleep(1)
log.info("Flask API on 0.0.0.0:%d", FLASK_PORT)
app.run(host="0.0.0.0", port=FLASK_PORT, threaded=True)