248 líneas
9.8 KiB
Python
248 líneas
9.8 KiB
Python
"""
|
|
Panel para OBS: servidor HTTP local que sirve `dock.html` y lo mantiene sincronizado con la app.
|
|
|
|
OBS Studio (28 o más) admite «Docks → Custom Browser Docks»: un panel acoplable que carga una URL.
|
|
Labmagic levanta este servidor en 127.0.0.1 y el panel habla con la misma conexión Bluetooth que
|
|
ya tiene la app (la cámara solo admite un proceso a la vez, así que el dock no se conecta solo).
|
|
|
|
GET / -> dock.html
|
|
GET /state -> estado actual en JSON
|
|
GET /events -> Server-Sent Events: un `data:` con el estado cada vez que cambia
|
|
POST /cmd -> JSON {"cmd": "...", ...} que la app ejecuta en su hilo de Tk
|
|
"""
|
|
import json
|
|
import os
|
|
import queue
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
DEFAULT_PORT = 8790 # 8765 lo usa el Organizador Labstream
|
|
|
|
|
|
class DockServer:
|
|
def __init__(self, cmd_queue: queue.Queue, port: int = DEFAULT_PORT, html_path: str = None):
|
|
self.cmd_queue = cmd_queue
|
|
self.port = port
|
|
self.html_path = html_path or os.path.join(HERE, "dock.html")
|
|
self._state = {}
|
|
self._state_json = "{}"
|
|
self._clients = set()
|
|
self._lock = threading.Lock()
|
|
self._httpd = None
|
|
self._thread = None
|
|
self.error = None
|
|
|
|
# ----------------------------------------------------------- ciclo de vida
|
|
def start(self):
|
|
server = self
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
protocol_version = "HTTP/1.1"
|
|
|
|
def log_message(self, *_a): # silencio en consola
|
|
pass
|
|
|
|
def _send(self, code, body: bytes, ctype="text/plain; charset=utf-8", extra=None):
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", ctype)
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.send_header("Cache-Control", "no-store")
|
|
for k, v in (extra or {}).items():
|
|
self.send_header(k, v)
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def do_GET(self):
|
|
path = self.path.split("?", 1)[0]
|
|
if path in ("/", "/index.html", "/dock.html"):
|
|
try:
|
|
with open(server.html_path, "rb") as fh:
|
|
self._send(200, fh.read(), "text/html; charset=utf-8")
|
|
except OSError as e:
|
|
self._send(500, f"No se encuentra dock.html: {e}".encode())
|
|
elif path == "/state":
|
|
self._send(200, server._state_json.encode("utf-8"), "application/json; charset=utf-8")
|
|
elif path == "/events":
|
|
self._sse()
|
|
elif path == "/icon.png":
|
|
icon = os.path.join(HERE, "icono.png")
|
|
try:
|
|
with open(icon, "rb") as fh:
|
|
self._send(200, fh.read(), "image/png")
|
|
except OSError:
|
|
self._send(404, b"")
|
|
else:
|
|
self._send(404, b"no")
|
|
|
|
def _sse(self):
|
|
q = queue.Queue(maxsize=200)
|
|
with server._lock:
|
|
server._clients.add(q)
|
|
try:
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.send_header("Connection", "keep-alive")
|
|
self.end_headers()
|
|
self.wfile.write(f"retry: 1500\ndata: {server._state_json}\n\n".encode("utf-8"))
|
|
self.wfile.flush()
|
|
while True:
|
|
try:
|
|
msg = q.get(timeout=15)
|
|
self.wfile.write(f"data: {msg}\n\n".encode("utf-8"))
|
|
except queue.Empty:
|
|
self.wfile.write(b": ping\n\n")
|
|
self.wfile.flush()
|
|
except (ConnectionError, OSError, ValueError):
|
|
pass
|
|
finally:
|
|
with server._lock:
|
|
server._clients.discard(q)
|
|
|
|
def do_POST(self):
|
|
path = self.path.split("?", 1)[0]
|
|
n = int(self.headers.get("Content-Length") or 0)
|
|
raw = self.rfile.read(n) if n else b""
|
|
if path != "/cmd":
|
|
self._send(404, b"no")
|
|
return
|
|
try:
|
|
data = json.loads(raw.decode("utf-8") or "{}")
|
|
if not isinstance(data, dict) or "cmd" not in data:
|
|
raise ValueError("falta cmd")
|
|
except (ValueError, UnicodeDecodeError) as e:
|
|
self._send(400, f"JSON no válido: {e}".encode())
|
|
return
|
|
data["_t"] = time.time()
|
|
server.cmd_queue.put(data)
|
|
self._send(204, b"")
|
|
|
|
# Si el puerto está ocupado por otro programa probamos los siguientes (la URL se guarda en config)
|
|
last_err = None
|
|
for port in range(self.port, self.port + 10):
|
|
try:
|
|
self._httpd = ThreadingHTTPServer(("127.0.0.1", port), Handler)
|
|
self._httpd.daemon_threads = True
|
|
self.port = port
|
|
break
|
|
except OSError as e:
|
|
last_err = e
|
|
self._httpd = None
|
|
if self._httpd is None:
|
|
self.error = f"No se pudo abrir ningún puerto entre {self.port} y {self.port + 9}: {last_err}"
|
|
return False
|
|
self._thread = threading.Thread(target=self._httpd.serve_forever, kwargs={"poll_interval": 0.5},
|
|
name="labmagic-dock", daemon=True)
|
|
self._thread.start()
|
|
return True
|
|
|
|
def stop(self):
|
|
if self._httpd is not None:
|
|
try:
|
|
self._httpd.shutdown()
|
|
self._httpd.server_close()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
self._httpd = None
|
|
|
|
@property
|
|
def url(self):
|
|
return f"http://127.0.0.1:{self.port}/"
|
|
|
|
@property
|
|
def clients(self):
|
|
with self._lock:
|
|
return len(self._clients)
|
|
|
|
# ----------------------------------------------------------- estado
|
|
def publish(self, state: dict):
|
|
"""Envía el estado a los paneles abiertos solo si ha cambiado."""
|
|
js = json.dumps(state, ensure_ascii=False, separators=(",", ":"))
|
|
if js == self._state_json:
|
|
return
|
|
self._state, self._state_json = state, js
|
|
with self._lock:
|
|
clients = list(self._clients)
|
|
for q in clients:
|
|
try:
|
|
q.put_nowait(js)
|
|
except queue.Full:
|
|
pass
|
|
|
|
|
|
# ------------------------------------------------------------------ registro en OBS
|
|
def obs_user_ini():
|
|
return os.path.join(os.environ.get("APPDATA", ""), "obs-studio", "user.ini")
|
|
|
|
|
|
def obs_running():
|
|
try:
|
|
import subprocess
|
|
out = subprocess.run(["tasklist", "/FI", "IMAGENAME eq obs64.exe", "/NH"], capture_output=True, text=True,
|
|
creationflags=0x08000000).stdout
|
|
return "obs64.exe" in out
|
|
except Exception: # noqa: BLE001
|
|
return False
|
|
|
|
|
|
def obs_add_browser_dock(title: str, url: str):
|
|
"""Añade el panel a «Docks → Custom Browser Docks» de OBS editando user.ini (OBS debe estar cerrado).
|
|
|
|
Devuelve (ok, mensaje)."""
|
|
ini = obs_user_ini()
|
|
if not os.path.exists(ini):
|
|
return False, "No se encuentra la configuración de OBS (user.ini). ¿Está instalado OBS 30 o superior?"
|
|
if obs_running():
|
|
return False, "Cierra OBS antes: guarda su configuración al salir y borraría el cambio."
|
|
with open(ini, "r", encoding="utf-8-sig") as fh:
|
|
lines = fh.read().splitlines()
|
|
docks = []
|
|
idx = None
|
|
section = None
|
|
basic_end = None
|
|
for i, line in enumerate(lines):
|
|
s = line.strip()
|
|
if s.startswith("[") and s.endswith("]"):
|
|
if section == "BasicWindow" and basic_end is None:
|
|
basic_end = i
|
|
section = s[1:-1]
|
|
elif section == "BasicWindow" and s.startswith("ExtraBrowserDocks="):
|
|
idx = i
|
|
try:
|
|
docks = json.loads(s.split("=", 1)[1] or "[]")
|
|
except ValueError:
|
|
docks = []
|
|
for d in docks:
|
|
if isinstance(d, dict) and d.get("url", "").rstrip("/") == url.rstrip("/"):
|
|
return True, "El panel ya estaba añadido en OBS (Docks → Labmagic)."
|
|
docks.append({"title": title, "url": url, "uuid": str(uuid.uuid4())})
|
|
new_line = "ExtraBrowserDocks=" + json.dumps(docks, ensure_ascii=False, separators=(",", ":"))
|
|
if idx is not None:
|
|
lines[idx] = new_line
|
|
else:
|
|
# Sin la clave: la añadimos al final de [BasicWindow] (o creamos la sección)
|
|
pos = None
|
|
for i, line in enumerate(lines):
|
|
if line.strip() == "[BasicWindow]":
|
|
pos = i + 1
|
|
while pos < len(lines) and not lines[pos].strip().startswith("["):
|
|
pos += 1
|
|
break
|
|
if pos is None:
|
|
lines += ["", "[BasicWindow]", new_line]
|
|
else:
|
|
lines.insert(pos, new_line)
|
|
backup = ini + ".labmagic.bak"
|
|
try:
|
|
if not os.path.exists(backup):
|
|
with open(ini, "rb") as src, open(backup, "wb") as dst:
|
|
dst.write(src.read())
|
|
with open(ini, "w", encoding="utf-8") as fh:
|
|
fh.write("\n".join(lines) + "\n")
|
|
except OSError as e:
|
|
return False, f"No se pudo escribir user.ini: {e}"
|
|
return True, "Panel añadido. Abre OBS: aparece en Docks → Labmagic (arrástralo donde quieras)."
|