Labmagic 1.0: control de la Blackmagic Pocket 4K por Bluetooth, panel para OBS y licencia MIT

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Este commit está contenido en:
2026-10-10 10:54:09 -05:00
commit 2ce1d650b5
Se han modificado 24 ficheros con 4356 adiciones y 0 borrados

10
.gitignore vendido Archivo normal
Ver fichero

@@ -0,0 +1,10 @@
# Estado local de la app
config.json
labmagic.log
# Compilación
__pycache__/
*.pyc
build/
dist/
*.spec

18
Compilar exe.bat Archivo normal
Ver fichero

@@ -0,0 +1,18 @@
@echo off
rem Genera dist\Labmagic.exe (un solo archivo, sin consola) con PyInstaller.
cd /d "%~dp0"
py -3.12 -m pip install --quiet pyinstaller bleak pillow
py -3.12 make_icon.py
py -3.12 -m PyInstaller --noconfirm --clean --onefile --windowed --name "Labmagic" ^
--icon "%~dp0icono.ico" --add-data "%~dp0icono.png;." --add-data "%~dp0dock.html;." ^
--collect-submodules winrt --collect-submodules bleak --copy-metadata bleak ^
--workpath "%TEMP%\labmagic-build" --specpath "%TEMP%\labmagic-build" --distpath "%~dp0dist" app.py
if errorlevel 1 (
echo.
echo Error al compilar.
pause
) else (
echo.
echo Listo: "%~dp0dist\Labmagic.exe"
pause
)

8
Iniciar Labmagic.bat Archivo normal
Ver fichero

@@ -0,0 +1,8 @@
@echo off
cd /d "%~dp0"
py -3.12 app.py
if errorlevel 1 (
echo.
echo La aplicacion ha terminado con error. Revisa el mensaje de arriba.
pause
)

53
LICENCIA-ES.md Archivo normal
Ver fichero

@@ -0,0 +1,53 @@
# Licencia y condiciones de uso (resumen en español)
Labmagic es software de **Labstream S.A.S.** (NIT 900.665.024-5, Bogotá, Colombia) y se
distribuye bajo la **Licencia MIT**. El texto legal que vale es el del archivo `LICENSE`
(en inglés, como es habitual en esta licencia); esto es una explicación.
## Qué puedes hacer
- **Descargarlo, instalarlo y usarlo** gratis, para lo que quieras, también en trabajos
comerciales.
- **Copiarlo, modificarlo y redistribuirlo**, entero o en parte, con o sin cambios.
- Incluirlo en otros programas, incluso de pago.
## Única condición
- Conservar el aviso de copyright y el texto de la licencia (`LICENSE`) en cualquier copia o
parte sustancial del programa.
## Sin garantía
El programa se entrega **tal cual**, sin garantía de ningún tipo. Labstream no responde por
daños derivados de su uso. En concreto, Labmagic envía órdenes a una cámara real: cambia
ajustes, mueve el objetivo, inicia y detiene grabaciones y puede apagar la cámara. Úsalo con
criterio y comprueba la configuración antes de un rodaje.
## Marcas y protocolo
- **Blackmagic**, **Blackmagic Design**, **Pocket Cinema Camera** y **Blackmagic RAW** son
marcas de Blackmagic Design Pty. Ltd. Labmagic no es un producto de Blackmagic Design ni
está respaldado por ella. Usa el *Blackmagic Camera Control Protocol*, que Blackmagic
publica en el manual de sus cámaras para que terceros lo implementen.
- **Metabones** es marca de Metabones Inc.
- **OBS Studio** es un proyecto de la OBS Project, licencia GPL v2; Labmagic no lo modifica ni
lo incluye, solo se acopla a él como panel de navegador.
- **Labstream** y su logotipo son de Labstream S.A.S.: la licencia MIT cubre el código, no
autoriza a usar la marca ni el logotipo para presentar otro producto como de Labstream.
## Datos personales
Labmagic no envía datos a internet ni a Labstream. Guarda en este PC un `config.json` con la
dirección Bluetooth de la cámara y tus preferencias, y un `labmagic.log` con el registro de
la sesión. El panel para OBS escucha solo en `127.0.0.1` (este equipo).
## Componentes de terceros
Ver `TERCEROS.md`: Python, Tcl/Tk, bleak, pywinrt y PyInstaller, con sus licencias.
## Aviso de Windows al descargar
El ejecutable no lleva firma digital de pago. Windows puede mostrar «Windows protegió tu PC»
(SmartScreen): pulsa «Más información» y «Ejecutar de todas formas». En equipos con **Smart
App Control** activado el .exe no abre; ahí se usa el acceso directo con Python
(`README.md`, «Por qué el .exe no abre en este PC»).

21
LICENSE Archivo normal
Ver fichero

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Labstream S.A.S. (NIT 900.665.024-5), Bogotá, Colombia
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

138
README.md Archivo normal
Ver fichero

@@ -0,0 +1,138 @@
# Labmagic
Aplicación sencilla de Windows para controlar una Blackmagic Pocket Cinema Camera por
Bluetooth LE: ver si está conectada, disparar el autofoco, mover foco y diafragma, grabar,
y ajustar ISO, obturador, balance de blancos, LUT y otras funciones.
Probada el 8 de octubre de 2026 con la cámara `A:4A5A3B39` (dirección `90:FD:9F:C4:AD:B2`),
protocolo Blackmagic 0.1.0, Windows 11 y Python 3.12.
## Cómo arrancarla
- **En este PC**: icono «Labmagic» en el escritorio y en el menú Inicio. Abre la app sin
consola (usa el `pythonw.exe` firmado de Python 3.12, que Windows sí deja ejecutar).
- **En otro PC**: `dist\Labmagic.exe` (un solo archivo, sin consola, no necesita Python).
Tarda 2 o 3 segundos en abrir: no hace falta pulsar dos veces.
- **Desde el código**: doble clic en `Iniciar Labmagic.bat` (o `py -3.12 app.py`).
Requiere Python 3.12 y `bleak` 3.x (`py -3.12 -m pip install -r requirements.txt`).
- **Recompilar el .exe** tras cambiar el código: doble clic en `Compilar exe.bat`.
### Por qué el .exe no abre en este PC
Este equipo tiene activado **Smart App Control** de Windows 11. Bloquea cualquier .exe sin firma
digital con reputación, y cada compilación de PyInstaller es un archivo nuevo sin firma, así
que lo bloquea («Una directiva de Control de aplicaciones bloqueó este archivo», evento 3118
de CodeIntegrity). Por eso aquí la app se lanza con los accesos directos a `pythonw.exe`.
Opciones para que el .exe funcione en este PC: firmarlo con un certificado de firma de código
reconocido, o desactivar Smart App Control (Seguridad de Windows > Control de aplicaciones y
navegador; una vez apagado no se puede volver a encender sin reinstalar Windows). En un PC sin
Smart App Control el .exe abre sin más.
Probado el 8 de octubre de 2026 con el .exe y la cámara real: conexión, autofoco, barras de
color, grabar y parar (la cámara confirma cada orden y el timecode corre durante la grabación).
## Panel dentro de OBS
Labmagic sirve una versión web de su panel mini en `http://127.0.0.1:8790/` (archivos
`obs_dock.py` y `dock.html`). OBS Studio 28 o superior la puede acoplar como un dock más con
**Docks → Custom Browser Docks** (nombre `Labmagic`, esa URL). El panel habla con la app, que es
quien tiene la conexión Bluetooth (la cámara solo admite un proceso a la vez): si Labmagic no está
abierta, el dock lo avisa y se conecta solo en cuanto la abres.
- Tiene lo mismo que la vista mini: HUD con timecode y celdas FPS · SHUTTER · IRIS · ISO · WB ·
TINT (pulsables, abren una hoja inferior estilo cámara), AF, PEAK (clic derecho: color), zebra,
falso color, AF auto, foco e iris con pasos, REC, línea de formato y «abrir Labmagic».
- Teclado con el dock enfocado: espacio = AF, ← → = foco (Mayús = pasos grandes).
- En **AJUSTES → Panel para OBS** se apaga o enciende, se copia la URL y el botón **Añadir a OBS**
lo registra en OBS (con OBS cerrado; guarda copia `user.ini.labmagic.bak`). Si el puerto 8790
está ocupado la app prueba los siguientes y guarda el elegido en `config.json` (`obs_port`).
## Uso
1. **Emparejar la cámara** con el botón **Emparejar (PIN)** de la app: quita el emparejamiento
viejo de Windows, pide uno nuevo y abre una ventana para escribir el PIN de 6 cifras que la
cámara muestra en pantalla (en la cámara, Bluetooth activado en Ajustes > Setup). Hay que
repetirlo si la cámara olvida el PC (la app lo avisa: «acceso denegado al servicio de control»
y el estado dice «Hace falta emparejar»). También vale emparejar desde Configuración de
Windows > Bluetooth y dispositivos.
2. Abrir la app. Busca sola las cámaras emparejadas y las que se anuncian y, con **Conectar al
abrir** activado (por defecto), conecta sola con la cámara de la última vez. Si no, elige la de
la lista y pulsa **Conectar**. El punto verde y la línea de estado indican conexión, cámara
encendida, versión de protocolo y alimentación.
3. **HUD superior, como la pantalla de la Pocket**: timecode en grande (se pone rojo y parpadea
un punto al grabar) y las celdas FPS · SHUTTER · IRIS · ISO · WB · TINT con los valores actuales.
Pulsar una celda abre un menú rápido: ángulos y velocidades de obturador, f/ del diafragma
(y Auto iris), ISO, Kelvin (y Auto WB) y tinte.
4. **Organización como la cámara, en pestañas**: PRINCIPAL (enfoque, diafragma y zoom, ISO /
obturador / balance de blancos, REC), GRABACIÓN (formato, códec, rango dinámico,
autoexposición), MONITOR (ayudas y overlays por salida, LUT, barras, reset de color) y AJUSTES
(cámara, emparejar, conectar al abrir, apagar, registro). El HUD queda siempre arriba.
5. Controles:
- **Enfoque**: botón grande de autofoco (también con la **barra espaciadora**); junto a él el
conmutador **PEAKING** (focus assist con líneas de color, color elegible: rojo, verde, azul,
blanco o negro) para ver dónde está enfocando; **AF automático cada N segundos** para mantener
enfocado a alguien sin tocar nada; deslizador de foco manual y pasos ◀ ▶.
**Con objetivos EF por adaptador (24-105 con el Metabones 0.64x) hace falta el firmware del
adaptador al día** (Metabones App, V5.10 en oct-2026): con el firmware viejo la cámara ignoraba
AF y foco manual; con el nuevo, medido el 9-oct-2026 sobre el HDMI, el AF, las posiciones
absolutas (0,05 / 0,5 / 0,95) y los pasos relativos (±0,2) mueven el objetivo. El objetivo EF
sigue sin informar de su posición: la app muestra la última posición enviada y tras un AF la
da por desconocida («—») hasta que se mueve el deslizador. Flechas ← → = pasos de 0,01,
con Mayús = 0,05; barra espaciadora = AF.
**Verificación del AF (9-oct-2026)**: el paquete que envía la app es byte a byte el del ejemplo
oficial del manual (`ff 04 00 00 00 01 00 00`). Midiendo la nitidez del HDMI en OBS a 10
muestras/s, las barras de color y el auto iris cambian la imagen al instante, pero tras el AF
el objetivo no se mueve nada (ni barrido ni cambio). Es decir, la cámara recibe la orden y el
objetivo no la ejecuta. Lista de comprobación: (1) interruptor del objetivo en **AF**;
(2) pulsar el botón **Focus** físico de la cámara: si tampoco enfoca, el adaptador no da AF con
ese objetivo (Metabones XL 0.64x necesita firmware 3.x actualizado con la app de Metabones por
USB; Viltrox EF-M2 igual); (3) si el botón físico enfoca y la app no, avisar para investigar.
- **Diafragma**: auto iris, deslizador 0 (abierto) a 1 (cerrado) y pasos; se muestra el f/ real
que informa la cámara.
- **Zoom**: deslizador para objetivos motorizados; la focal en mm se muestra siempre.
- **Grabación**: botón GRABAR / PARAR, estado de transporte, formato y códec. Debajo, el panel
**Formato de grabación**: códec (BRAW Q0/Q1/Q3/Q5/3:1/5:1/8:1/12:1, ProRes HQ/422/LT/Proxy),
resolución (DCI 4K, 4096×1720, UHD, 2.6K, HD), fotogramas (23.98 a 60), fotogramas del sensor
para off-speed y ventana del sensor; **Aplicar formato** envía códec (10.0) y formato (1.9). La
cámara responde con el formato real que ha aceptado y el panel se actualiza con él.
- **Monitor y utilidades**: selector **Aplicar en** (HDMI, pantalla LCD o ambas; por defecto HDMI
porque la cámara va remota). Las ayudas (zebra, peaking, falso color) y los overlays (texto de
estado, guías de encuadre, señal limpia) se envían con el campo «pantallas destino» del
protocolo (4.1 y 3.0), así que afectan solo a la salida elegida.
- **Exposición y color**: ISO, ángulo o velocidad de obturador, balance de blancos (Kelvin y
tinte, presets y Auto WB), modo de autoexposición, rango dinámico y LUT de monitor.
- **Monitor y utilidades**: zebra, peaking, falso color, barras de color, reset de corrección
de color y apagar la cámara (con confirmación).
- **Registro**: todo lo que se envía y lo que la cámara responde, con nombres legibles.
## Detalles que conviene saber
- **Al conectar, la app pide a la cámara toda su configuración.** La cámara solo la envía sola
cuando el enlace Bluetooth es nuevo, pero escribir el valor 0x21 (Camera Ready sin «Initial
Payload Received») en la característica Camera Status hace que la reenvíe siempre. Así el HUD
(FPS, obturador, iris, ISO, WB, tinte), el formato, el códec y el objetivo se rellenan en unos
6 segundos tras cada conexión, aunque Windows reutilice un enlace ya abierto.
- En este firmware la operación «offset» del protocolo se comporta como asignación absoluta,
por eso los pasos ◀ ▶ del diafragma solo se activan cuando se conoce el valor actual.
- Las ayudas de monitor (zebra, peaking, falso color) y el apagado por Bluetooth dependen del
firmware; si no cambian nada en la cámara es que no las acepta por este canal.
- Los nombres de las variantes de códec (BRAW 5:1, ProRes HQ, etc.) siguen la tabla del
Blackmagic Camera Control Developer Guide; si alguno no coincide con la cámara, es solo etiqueta.
- `config.json` guarda la última cámara usada y el tamaño de la ventana.
- Solo puede haber una copia abierta: si se vuelve a lanzar, trae al frente la que ya está.
- Estética: negro y gris oscuro como la pantalla de la cámara, barra de título oscura. Icono:
cuadrado rojo con bordes redondeados, borde blanco y el «lab» del wordmark real de Labstream
más un punto blanco (opción 2 de `iconos/opciones.png`; `make_icon.py` genera las cuatro
opciones a partir de `0_Modelo Propuestas/Logos/Logo Blanco.png`; para cambiarla, ajusta
`OPCION_POR_DEFECTO` y vuelve a ejecutarlo).
- El «acceso denegado al servicio de control» al conectar casi siempre significa que otra
aplicación de este PC ya está conectada a la cámara (otra copia de Labmagic, un script o la
ventana de Configuración > Bluetooth); solo si no es eso hay que volver a emparejar.
## Archivos
- `app.py`: ventana (Tkinter).
- `bmpcc_ble.py`: protocolo Blackmagic Camera Control sobre BLE (bleak), búsqueda de cámaras
emparejadas y anunciadas, envío y decodificación de parámetros.
- `Iniciar Labmagic.bat`: lanzador.
- `requirements.txt`: dependencias de Python.

17
TERCEROS.md Archivo normal
Ver fichero

@@ -0,0 +1,17 @@
# Componentes de terceros incluidos o usados por Labmagic
| Componente | Para qué | Licencia | Dónde |
|---|---|---|---|
| Python 3.12 | lenguaje y biblioteca estándar | PSF License (compatible con MIT, permite redistribuir) | https://www.python.org/ |
| Tcl/Tk (tkinter) | ventanas y controles | Tcl/Tk License (tipo BSD) | https://www.tcl.tk/ |
| bleak 3.x | Bluetooth LE en Windows | MIT | https://github.com/hbldh/bleak |
| pywinrt (winrt-*) | acceso a la API de Windows para emparejar | MIT | https://github.com/pywinrt/pywinrt |
| PyInstaller | empaqueta `Labmagic.exe` | GPL 2.0 con excepción del *bootloader*: el ejecutable resultante puede distribuirse bajo la licencia del programa (MIT) | https://pyinstaller.org/ |
| Fuentes Segoe UI y Consolas | tipografía de la interfaz | vienen con Windows; no se redistribuyen | Microsoft |
| Blackmagic Camera Control Protocol | órdenes a la cámara por Bluetooth | protocolo publicado por Blackmagic Design en el manual de sus cámaras para implementaciones de terceros; no incluye código de Blackmagic | https://www.blackmagicdesign.com/ |
Ninguno de estos componentes obliga a publicar el código de Labmagic; aun así, se publica
entero bajo MIT en https://git.labstreamsas.com/Labstream/labmagic.
Los textos completos de PSF, Tcl/Tk, MIT y GPL 2.0 con excepción se encuentran en los enlaces
indicados y dentro del paquete de cada componente (`py -3.12 -m pip show -f bleak`, etc.).

2457
app.py Archivo normal

La diferencia del archivo ha sido suprimido porque es demasiado grande Cargar Diff

876
bmpcc_ble.py Archivo normal
Ver fichero

@@ -0,0 +1,876 @@
"""
Control de cámaras Blackmagic (Pocket Cinema Camera 4K/6K) por Bluetooth LE.
Implementa el "Blackmagic Camera Control Protocol" sobre el servicio BLE de la
cámara. Toda la comunicación corre en un hilo propio con su bucle asyncio; la
interfaz gráfica habla con él mediante BleWorker.submit() y recibe eventos por
una cola (queue.Queue).
"""
import asyncio
import json
import math
import platform
import queue
import re
import struct
import subprocess
import threading
import time
from bleak import BleakClient, BleakScanner
def _run_powershell(command: str) -> str:
"""Ejecuta un comando de PowerShell sin ventana y devuelve su salida en UTF-8."""
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
full = "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; " + command
res = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command", full],
capture_output=True,
timeout=20,
startupinfo=startupinfo,
)
return res.stdout.decode("utf-8", errors="replace")
# ---------------------------------------------------------------------------
# UUIDs del servicio Blackmagic Camera Service
# ---------------------------------------------------------------------------
SERVICE_UUID = "291d567a-6d75-11e6-8b77-86f30ca893d3"
CH_OUT = "5dd3465f-1aee-4299-8493-d2eca2f8e1bb" # Outgoing Camera Control (write)
CH_IN = "b864e140-76a0-416a-bf30-5876504537d9" # Incoming Camera Control (indicate)
CH_TIMECODE = "6d8f2110-86f1-41bf-9afb-451d87e976c8" # Timecode (notify)
CH_STATUS = "7fe8691d-95dc-4fc5-8abd-ca74339b51b9" # Camera Status (read/write/notify)
CH_DEVNAME = "ffac0c52-c9fb-41a0-b063-cc76282eb89c" # Device Name (write)
CH_PROTO_PREFIX = "8f1fd018-b508-456f-8f82-" # Protocol Version (read); el sufijo cambia
# Tipos de dato del protocolo
T_VOID, T_INT8, T_INT16, T_INT32, T_INT64, T_STRING, T_FIXED16 = 0, 1, 2, 3, 4, 5, 128
# Operaciones
OP_ASSIGN, OP_OFFSET, OP_REPORT = 0, 1, 2
# Nombres legibles de (categoría, parámetro) para el registro
PARAM_NAMES = {
(0, 0): "Foco",
(0, 1): "Autofoco",
(0, 2): "Apertura (AV)",
(0, 3): "Apertura normalizada",
(0, 4): "Apertura (ordinal)",
(0, 5): "Auto iris",
(0, 6): "Estabilización óptica",
(0, 7): "Zoom (mm)",
(0, 8): "Zoom normalizado",
(0, 9): "Zoom continuo",
(1, 0): "Modo de vídeo",
(1, 1): "Ganancia (antigua)",
(1, 2): "Balance de blancos",
(1, 3): "Auto balance de blancos",
(1, 4): "Restaurar auto WB",
(1, 5): "Exposición (µs)",
(1, 6): "Exposición (ordinal)",
(1, 7): "Rango dinámico",
(1, 8): "Nitidez",
(1, 9): "Formato de grabación",
(1, 10): "Modo de autoexposición",
(1, 11): "Ángulo de obturador",
(1, 12): "Velocidad de obturador",
(1, 13): "Ganancia (dB)",
(1, 14): "ISO",
(1, 15): "LUT de monitor",
(1, 16): "Filtro ND",
(2, 0): "Nivel de micrófono",
(2, 1): "Nivel de auriculares",
(2, 3): "Nivel de altavoz",
(2, 4): "Tipo de entrada de audio",
(2, 5): "Niveles de entrada",
(2, 6): "Alimentación phantom",
(3, 0): "Overlays activos",
(3, 3): "Overlays",
(4, 0): "Brillo de pantalla",
(4, 1): "Ayudas de exposición/enfoque",
(4, 2): "Nivel de zebra",
(4, 3): "Nivel de peaking",
(4, 4): "Barras de color (s)",
(4, 5): "Focus assist",
(5, 0): "Brillo tally",
(6, 0): "Fuente de referencia",
(7, 0): "Reloj",
(7, 1): "Idioma",
(8, 0): "Color: lift",
(8, 1): "Color: gamma",
(8, 2): "Color: gain",
(8, 3): "Color: offset",
(8, 4): "Color: contraste",
(8, 5): "Color: luma mix",
(8, 6): "Color: tono/saturación",
(8, 7): "Color: reset",
(9, 0): "Alimentación (mV, %, ?)",
(9, 4): "Timecode",
(10, 0): "Códec",
(10, 1): "Transporte",
(12, 0): "Metadatos: reel",
(12, 1): "Metadatos: etiquetas de escena",
(12, 2): "Metadatos: escena",
(12, 3): "Metadatos: toma",
(12, 4): "Metadatos: toma buena",
(12, 5): "Metadatos: id de cámara",
(12, 6): "Metadatos: operador",
(12, 7): "Metadatos: director",
(12, 8): "Metadatos: proyecto",
(12, 9): "Objetivo: tipo",
(12, 10): "Objetivo: iris",
(12, 11): "Objetivo: focal",
(12, 12): "Objetivo: distancia",
(12, 13): "Objetivo: filtro",
}
# ---------------------------------------------------------------------------
# Codificación / decodificación
# ---------------------------------------------------------------------------
def enc_fixed16(value: float) -> bytes:
"""fixed16 = 5.11 con signo (valor * 2048), little-endian."""
v = int(round(value * 2048))
v = max(-32768, min(32767, v))
return struct.pack("<h", v)
def build_packet(category: int, parameter: int, dtype: int, op: int, data: bytes = b"") -> bytes:
"""Paquete del protocolo: cabecera de 4 bytes + comando 0 + datos, relleno a 4 bytes."""
length = 4 + len(data)
pkt = bytes([255, length, 0, 0, category, parameter, dtype, op]) + data
pkt += b"\x00" * ((-len(pkt)) % 4)
return pkt
def decode_data(dtype: int, data: bytes):
"""Convierte los bytes de datos a una lista de valores Python según el tipo."""
try:
if dtype == T_VOID:
return list(data)
if dtype == T_INT8:
return list(struct.unpack(f"<{len(data)}b", data))
if dtype == T_INT16:
n = len(data) // 2
return list(struct.unpack(f"<{n}h", data[: n * 2]))
if dtype == T_INT32:
n = len(data) // 4
return list(struct.unpack(f"<{n}i", data[: n * 4]))
if dtype == T_INT64:
n = len(data) // 8
return list(struct.unpack(f"<{n}q", data[: n * 8]))
if dtype == T_STRING:
return [data.decode("utf-8", errors="replace").rstrip("\x00")]
if dtype == T_FIXED16:
n = len(data) // 2
return [v / 2048.0 for v in struct.unpack(f"<{n}h", data[: n * 2])]
except struct.error:
pass
return list(data)
def parse_packets(buf: bytes):
"""Trocea una notificación en paquetes (cat, par, tipo, op, datos). Puede venir más de uno."""
out = []
i = 0
n = len(buf)
while i + 8 <= n:
length = buf[i + 1]
if length < 4:
break
cat, par, dtype, op = buf[i + 4], buf[i + 5], buf[i + 6], buf[i + 7]
data = bytes(buf[i + 8 : i + 4 + length])
out.append((cat, par, dtype, op, data))
i += 4 + length
i += (-i) % 4
return out
def decode_timecode(data: bytes) -> str:
"""Timecode BCD: bytes [FF, SS, MM, HH]; el bit alto de HH marca drop-frame."""
if len(data) < 4:
return "--:--:--:--"
f, s, m, h = data[0], data[1], data[2], data[3]
sep = ";" if h & 0x80 else ":"
return f"{h & 0x7F:02x}:{m:02x}:{s:02x}{sep}{f:02x}"
# ---------------------------------------------------------------------------
# Hilo con bucle asyncio
# ---------------------------------------------------------------------------
class BleWorker:
"""Ejecuta un bucle asyncio en un hilo aparte para que Tk no se bloquee."""
def __init__(self):
self.loop = asyncio.new_event_loop()
self._thread = threading.Thread(target=self._run, name="ble-loop", daemon=True)
self._thread.start()
def _run(self):
asyncio.set_event_loop(self.loop)
self.loop.run_forever()
def submit(self, coro):
"""Lanza una corrutina en el bucle y devuelve un concurrent.futures.Future."""
return asyncio.run_coroutine_threadsafe(coro, self.loop)
def stop(self):
self.loop.call_soon_threadsafe(self.loop.stop)
# ---------------------------------------------------------------------------
# Cámara
# ---------------------------------------------------------------------------
class Camera:
"""Una cámara Blackmagic por BLE. Los métodos async deben correr en el bucle del worker."""
def __init__(self, events: queue.Queue):
self.events = events
self.client = None
self.device = None
self.devices = {} # address -> BLEDevice (de la última búsqueda)
self.state = {} # (cat, par) -> (dtype, data bytes, valores)
self.protocol_version = ""
self.power_on = None
self._send_lock = asyncio.Lock()
self._proto_uuid = None
self._payload_count = 0
self._payload_event = None
# ----- eventos hacia la interfaz -------------------------------------
def emit(self, kind: str, **payload):
payload["kind"] = kind
payload["t"] = time.time()
self.events.put(payload)
def log(self, text: str):
self.emit("log", text=text)
# ----- búsqueda y conexión ------------------------------------------
async def scan(self, timeout: float = 5.0, emit_event: bool = True):
self.log(f"Buscando cámaras ({timeout:.0f} s)...")
found = await BleakScanner.discover(timeout=timeout, return_adv=True)
cams = []
for address, (dev, adv) in found.items():
uuids = [u.lower() for u in (adv.service_uuids or [])]
name = dev.name or adv.local_name or ""
if SERVICE_UUID in uuids or name.startswith("A:"):
self.devices[address] = dev
cams.append({"address": address, "name": name or "(sin nombre)", "rssi": adv.rssi})
cams.sort(key=lambda c: -c["rssi"])
if emit_event:
self.emit("devices", devices=cams)
self.log(f"Anunciándose ahora: {len(cams)} cámara(s).")
return cams
async def paired_cameras(self):
"""Cámaras Blackmagic ya emparejadas en Windows (aunque no se estén anunciando).
Se consulta el árbol PnP de Windows con PowerShell: cada dispositivo BLE emparejado
aparece como BTHLE\\DEV_<MAC> con su nombre.
"""
cams = []
try:
cmd = (
"Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue | "
"Where-Object { $_.InstanceId -like 'BTHLE\\DEV_*' } | "
"Select-Object FriendlyName, InstanceId, Status | ConvertTo-Json -Compress"
)
out = await asyncio.to_thread(_run_powershell, cmd)
if out.strip():
items = json.loads(out)
if isinstance(items, dict):
items = [items]
for it in items:
name = (it.get("FriendlyName") or "").strip()
inst = it.get("InstanceId") or ""
if not (name.startswith("A:") or "blackmagic" in name.lower()):
continue
m = re.search(r"DEV_([0-9A-Fa-f]{12})", inst)
if not m:
continue
mac = m.group(1).upper()
addr = ":".join(mac[i : i + 2] for i in range(0, 12, 2))
cams.append({"address": addr, "name": name, "rssi": None, "paired": True})
except Exception as e: # noqa: BLE001
self.log(f"No se pudo consultar los dispositivos emparejados: {e}")
return cams
async def find_cameras(self, timeout: float = 5.0):
"""Une las emparejadas en Windows con las que se anuncian ahora mismo."""
paired = await self.paired_cameras()
seen = await self.scan(timeout, emit_event=False)
by_addr = {c["address"].upper(): dict(c, paired=False) for c in seen}
for c in paired:
key = c["address"].upper()
if key in by_addr:
by_addr[key]["paired"] = True
else:
by_addr[key] = c
cams = sorted(by_addr.values(), key=lambda c: (-(c["rssi"] or -999)))
self.emit("devices", devices=cams)
return cams
async def connect(self, address: str, name: str | None = None, fresh_retry: bool = False):
if self.client and self.client.is_connected:
await self.disconnect()
self.emit("connecting", address=address)
dev = self.devices.get(address)
if dev is None:
# En Windows basta la dirección: no hace falta que la cámara se esté anunciando
# (una cámara ya conectada a nivel de sistema deja de anunciarse).
from bleak.backends.device import BLEDevice
dev = BLEDevice(address, name, None)
elif name and not dev.name:
dev = type(dev)(dev.address, name, dev.details)
self.device = dev
self.state = {}
got_payload = False
for attempt in (1, 2):
self.client = BleakClient(dev, disconnected_callback=self._on_disconnected, timeout=30.0)
self.log(f"Conectando con {dev.name or address}..." if attempt == 1 else "Conectando de nuevo con enlace nuevo...")
try:
await self.client.connect()
got_payload = await self._setup_after_connect(address, dev)
except Exception:
c, self.client = self.client, None
try:
await c.disconnect()
except Exception: # noqa: BLE001
pass
raise
if got_payload or not fresh_retry or attempt == 2:
break
# Sin volcado inicial: el enlace Bluetooth venía reutilizado. Lo cerramos y esperamos a
# que Windows lo suelte para que la próxima conexión sea un enlace nuevo.
self.log("La cámara no ha enviado su configuración (enlace Bluetooth reutilizado). "
"Reinicio el enlace para forzarla...")
self.emit("connecting", address=address, text="Reiniciando el enlace Bluetooth para leer la configuración...")
c, self.client = self.client, None
try:
await c.disconnect()
except Exception: # noqa: BLE001
pass
if not await self._wait_link_down(address, 20.0):
self.log("Windows mantiene el enlace abierto: suele ser la ventana de Configuración > "
"Bluetooth. Ciérrala y vuelve a conectar para que lleguen todos los valores.")
self.emit("link_held", address=address)
self.emit(
"connected",
address=address,
name=dev.name or address,
protocol=self.protocol_version,
mtu=self.client.mtu_size,
payload=got_payload,
)
if got_payload:
self.log(f"Conectada. Protocolo {self.protocol_version or '?'}. Configuración recibida.")
else:
self.log(f"Conectada. Protocolo {self.protocol_version or '?'}. Sin configuración inicial: los "
"valores se irán rellenando según los toques.")
async def _wait_link_down(self, address: str, timeout: float) -> bool:
"""Espera a que Windows suelte el enlace BLE con la cámara (connection_status = 0)."""
try:
from winrt.windows.devices.bluetooth import BluetoothLEDevice
except Exception: # noqa: BLE001
await asyncio.sleep(3.0)
return True
addr = int(address.replace(":", ""), 16)
t0 = time.time()
while time.time() - t0 < timeout:
d = await BluetoothLEDevice.from_bluetooth_address_async(addr)
status = d.connection_status if d is not None else 0
if d is not None:
d.close()
if status == 0:
await asyncio.sleep(0.5)
return True
await asyncio.sleep(1.0)
return False
async def _setup_after_connect(self, address: str, dev) -> bool:
# Localizar la característica de versión de protocolo (el sufijo del UUID varía)
self._proto_uuid = None
found_service = False
for svc in self.client.services:
if svc.uuid.lower() != SERVICE_UUID:
continue
found_service = True
for ch in svc.characteristics:
if ch.uuid.lower().startswith(CH_PROTO_PREFIX):
self._proto_uuid = ch.uuid
if not found_service:
# Windows oculta el servicio cuando no puede cifrar el enlace: la cámara ya no
# reconoce el emparejamiento guardado (o nunca se emparejó).
self.emit("needs_pairing", address=address)
raise RuntimeError(
"Acceso denegado al servicio de control de la cámara. Causas, por orden: (1) otra "
"aplicación de este PC ya está conectada a la cámara (otra copia de Labmagic, un "
"script de prueba o la ventana de Configuración > Bluetooth): ciérrala y vuelve a "
"conectar; (2) la cámara ya no acepta el emparejamiento guardado: pulsa «Emparejar "
"(PIN)» y escribe el PIN que muestre la cámara."
)
if self._proto_uuid:
try:
raw = await self.client.read_gatt_char(self._proto_uuid)
self.protocol_version = raw.decode("utf-8", errors="replace").strip("\x00 ")
except Exception as e: # noqa: BLE001
self.protocol_version = "?"
self.log(f"No se pudo leer la versión de protocolo: {e}")
# Nombre con el que aparecemos en el menú Bluetooth de la cámara
try:
name = f"{platform.node()[:18]} (PC)"
await self.client.write_gatt_char(CH_DEVNAME, name.encode("utf-8"), response=True)
except Exception as e: # noqa: BLE001
self.log(f"No se pudo enviar el nombre del equipo: {e}")
# Estado (encendida/apagada)
try:
st = await self.client.read_gatt_char(CH_STATUS)
self._handle_status(bytes(st))
except Exception as e: # noqa: BLE001
msg = str(e).lower()
if "auth" in msg or "access" in msg or "0x05" in msg or "0x0f" in msg:
raise RuntimeError(
"La cámara exige emparejamiento. Empareja desde Configuración de Windows > "
"Bluetooth con el PIN que muestra la cámara y vuelve a conectar."
) from e
raise
self._payload_count = 0
self._payload_event = asyncio.Event()
await self.client.start_notify(CH_STATUS, lambda _c, d: self._handle_status(bytes(d)))
await self.client.start_notify(CH_TIMECODE, lambda _c, d: self._handle_timecode(bytes(d)))
await self.client.start_notify(CH_IN, lambda _c, d: self._handle_incoming(bytes(d)))
# Pedir el volcado completo de configuración. La cámara lo envía sola solo cuando el enlace
# Bluetooth es nuevo; escribir "Camera Ready" (0x20) con "Initial Payload Received" (0x10) a
# cero (y el bit de encendido) hace que lo reenvíe aunque Windows reutilice el enlace.
try:
await self.client.write_gatt_char(CH_STATUS, bytes([0x21]), response=True)
except Exception as e: # noqa: BLE001
self.log(f"No se pudo pedir la configuración a la cámara: {e}")
# El volcado llega en varios bloques a lo largo de unos segundos; esperamos a tener una
# cantidad razonable de parámetros (o 6 s) antes de dar la conexión por completa.
t0 = time.time()
while time.time() - t0 < 6.0 and self._payload_count < 8:
await asyncio.sleep(0.2)
return self._payload_count >= 8
async def disconnect(self):
c = self.client
self.client = None
if c is not None:
try:
await c.disconnect()
except Exception as e: # noqa: BLE001
self.log(f"Error al desconectar: {e}")
self.emit("disconnected", reason="manual")
def _on_disconnected(self, client):
# Solo cuenta si es el cliente actual (los avisos de clientes antiguos llegan con retraso)
if self.client is not None and client is self.client:
self.client = None
self.log("La cámara se ha desconectado.")
self.emit("disconnected", reason="remote")
@property
def connected(self) -> bool:
return bool(self.client and self.client.is_connected)
# ----- emparejamiento con PIN (sin pasar por Configuración de Windows) ----------
async def pair(self, address: str, ask_pin, name: str | None = None):
"""Quita el emparejamiento antiguo de Windows y empareja de nuevo con el PIN de la cámara.
ask_pin: función sin argumentos que se llama desde un hilo auxiliar y debe devolver el
PIN escrito por el usuario (o None para cancelar). Puede bloquear mientras espera.
Al terminar con éxito, conecta.
"""
from winrt.windows.devices.bluetooth import BluetoothLEDevice
from winrt.windows.devices.enumeration import (
DeviceInformation,
DevicePairingKinds,
DevicePairingProtectionLevel,
)
if self.connected:
await self.disconnect()
self.emit("pairing", stage="start", address=address)
dev = await BluetoothLEDevice.from_bluetooth_address_async(int(address.replace(":", ""), 16))
if dev is None:
raise RuntimeError("No se encuentra la cámara. ¿Está encendida y con el Bluetooth activado?")
info = await DeviceInformation.create_from_id_async(dev.device_information.id)
pairing = info.pairing
if pairing.is_paired:
self.log("Quitando el emparejamiento anterior de Windows...")
res = await pairing.unpair_async()
self.log(f"Emparejamiento anterior eliminado (estado {int(res.status)}).")
await asyncio.sleep(1.5)
info = await DeviceInformation.create_from_id_async(dev.device_information.id)
pairing = info.pairing
if not pairing.can_pair:
raise RuntimeError("Windows indica que este dispositivo no se puede emparejar ahora mismo.")
custom = pairing.custom
log = self.log
def handler(_sender, args):
kind = int(args.pairing_kind)
if kind == int(DevicePairingKinds.PROVIDE_PIN):
deferral = args.get_deferral()
def work():
try:
pin = ask_pin()
if pin:
args.accept_with_pin(str(pin).strip())
else:
log("Emparejamiento cancelado por el usuario.")
except Exception as e: # noqa: BLE001
log(f"Error al aceptar el PIN: {e}")
finally:
deferral.complete()
threading.Thread(target=work, name="pin-prompt", daemon=True).start()
elif kind == int(DevicePairingKinds.DISPLAY_PIN):
log(f"La cámara pide este PIN: {args.pin}")
args.accept()
else:
args.accept()
token = custom.add_pairing_requested(handler)
try:
try:
kinds = DevicePairingKinds(
int(DevicePairingKinds.PROVIDE_PIN)
| int(DevicePairingKinds.CONFIRM_ONLY)
| int(DevicePairingKinds.DISPLAY_PIN)
)
except Exception: # noqa: BLE001
kinds = DevicePairingKinds.PROVIDE_PIN
self.log("Solicitando emparejamiento: mira la pantalla de la cámara, mostrará un PIN.")
self.emit("pairing", stage="requested", address=address)
result = await custom.pair_with_protection_level_async(
kinds, DevicePairingProtectionLevel.ENCRYPTION_AND_AUTHENTICATION
)
finally:
custom.remove_pairing_requested(token)
status = int(result.status)
names = {
0: "emparejada", 1: "la cámara no está lista para emparejar", 2: "no emparejada",
3: "ya estaba emparejada", 4: "la cámara rechazó la conexión", 5: "demasiadas conexiones",
6: "fallo de hardware", 7: "tiempo de espera agotado (no llegó el PIN a tiempo)",
8: "autenticación no permitida", 9: "fallo de autenticación (PIN incorrecto)",
10: "sin perfiles compatibles", 11: "no se alcanzó el nivel de protección",
12: "acceso denegado", 13: "datos de ceremonia no válidos", 14: "emparejamiento cancelado",
15: "ya hay una operación en curso", 16: "falta el manejador de PIN",
17: "rechazado por el manejador", 18: "el dispositivo ya tiene asociación", 19: "fallo",
}
if status not in (0, 3):
self.emit("pairing", stage="failed", address=address, status=status)
raise RuntimeError(f"No se pudo emparejar: {names.get(status, status)} (código {status}).")
self.log("Cámara emparejada con Windows. Conectando...")
self.emit("pairing", stage="done", address=address)
await asyncio.sleep(1.0)
await self.connect(address, name)
# ----- notificaciones -----------------------------------------------
def _handle_status(self, data: bytes):
if not data:
return
self.power_on = bool(data[0] & 0x01)
self.emit("status", raw=data.hex(), power_on=self.power_on)
def _handle_timecode(self, data: bytes):
pk = parse_packets(data)
if pk:
tc = decode_timecode(pk[-1][4])
elif len(data) >= 4:
tc = decode_timecode(data[-4:])
else:
return
self.emit("timecode", timecode=tc)
def _handle_incoming(self, data: bytes):
for cat, par, dtype, op, raw in parse_packets(data):
if cat == 255 and par == 255:
# Fin de bloque del volcado inicial
if self._payload_event is not None:
self._payload_event.set()
continue
values = decode_data(dtype, raw)
self.state[(cat, par)] = (dtype, raw, values)
if cat != 9:
self._payload_count += 1
self.emit("param", cat=cat, par=par, dtype=dtype, op=op, raw=raw, values=values)
# ----- envío ----------------------------------------------------------
async def send(self, category: int, parameter: int, dtype: int, op: int, data: bytes = b""):
if not self.connected:
raise RuntimeError("No hay cámara conectada.")
pkt = build_packet(category, parameter, dtype, op, data)
async with self._send_lock:
await self.client.write_gatt_char(CH_OUT, pkt, response=True)
self.emit("sent", cat=category, par=parameter, dtype=dtype, op=op, raw=data)
# --- Objetivo --------------------------------------------------------
async def autofocus(self):
await self.send(0, 1, T_VOID, OP_ASSIGN)
async def set_focus(self, value: float):
await self.send(0, 0, T_FIXED16, OP_ASSIGN, enc_fixed16(max(0.0, min(1.0, value))))
async def offset_focus(self, delta: float):
await self.send(0, 0, T_FIXED16, OP_OFFSET, enc_fixed16(delta))
async def auto_iris(self):
await self.send(0, 5, T_VOID, OP_ASSIGN)
async def set_aperture_norm(self, value: float):
await self.send(0, 3, T_FIXED16, OP_ASSIGN, enc_fixed16(max(0.0, min(1.0, value))))
async def set_aperture_fstop(self, fnumber: float):
"""Apertura en f/: el protocolo usa el Aperture Value, AV = 2·log2(f)."""
av = 2.0 * math.log2(max(0.7, float(fnumber)))
await self.send(0, 2, T_FIXED16, OP_ASSIGN, enc_fixed16(av))
async def offset_aperture_norm(self, delta: float):
await self.send(0, 3, T_FIXED16, OP_OFFSET, enc_fixed16(delta))
async def set_zoom_norm(self, value: float):
await self.send(0, 8, T_FIXED16, OP_ASSIGN, enc_fixed16(max(0.0, min(1.0, value))))
async def set_ois(self, enabled: bool):
await self.send(0, 6, T_VOID, OP_ASSIGN, bytes([1 if enabled else 0]))
# --- Vídeo / exposición ---------------------------------------------
async def set_iso(self, iso: int):
await self.send(1, 14, T_INT32, OP_ASSIGN, struct.pack("<i", int(iso)))
async def set_shutter_angle(self, degrees: float):
await self.send(1, 11, T_INT32, OP_ASSIGN, struct.pack("<i", int(round(degrees * 100))))
async def set_shutter_speed(self, denominator: int):
await self.send(1, 12, T_INT32, OP_ASSIGN, struct.pack("<i", int(denominator)))
async def set_white_balance(self, kelvin: int, tint: int):
kelvin = max(2500, min(10000, int(kelvin)))
tint = max(-50, min(50, int(tint)))
await self.send(1, 2, T_INT16, OP_ASSIGN, struct.pack("<hh", kelvin, tint))
async def auto_white_balance(self):
await self.send(1, 3, T_VOID, OP_ASSIGN)
async def set_dynamic_range(self, mode: int):
await self.send(1, 7, T_INT8, OP_ASSIGN, bytes([mode & 0xFF]))
async def set_auto_exposure_mode(self, mode: int):
await self.send(1, 10, T_INT8, OP_ASSIGN, bytes([mode & 0xFF]))
async def set_display_lut(self, lut: int, enabled: bool):
await self.send(1, 15, T_INT8, OP_ASSIGN, bytes([lut & 0xFF, 1 if enabled else 0]))
async def set_gain_db(self, db: int):
await self.send(1, 13, T_INT8, OP_ASSIGN, struct.pack("<b", max(-128, min(127, int(db)))))
# --- Grabación -------------------------------------------------------
async def set_recording(self, recording: bool):
cur = self.state.get((10, 1))
if cur and len(cur[1]) >= 5:
data = bytearray(cur[1])
else:
data = bytearray([0, 0, 0, 0, 0])
data[0] = 2 if recording else 0 # 0 = preview, 1 = play, 2 = record
data[1] = 0 # velocidad: 0 = pausa / grabación normal
await self.send(10, 1, T_INT8, OP_ASSIGN, bytes(data))
# --- Monitor ---------------------------------------------------------
# Pantallas destino (segundo uint16 de 3.0 Overlay enables y 4.1 Exposure and focus tools)
OUT_LCD, OUT_HDMI, OUT_EVF, OUT_SDI_MAIN, OUT_SDI_FRONT = 1, 2, 4, 8, 16
async def set_tools(self, zebra: bool, focus_assist: bool, false_colour: bool, targets: int = 3):
"""4.1: [bits zebra 0x1, focus assist 0x2, falso color 0x4] + [pantallas destino]."""
bits = (1 if zebra else 0) | (2 if focus_assist else 0) | (4 if false_colour else 0)
await self.send(4, 1, T_INT16, OP_ASSIGN, struct.pack("<HH", bits, targets & 0xFFFF))
async def set_overlays(self, status: bool, frame_guides: bool, clean_feed: bool, targets: int = 3):
"""3.0: [bits texto de estado 0x1, guías 0x2, señal limpia 0x4] + [pantallas destino]."""
bits = (1 if status else 0) | (2 if frame_guides else 0) | (4 if clean_feed else 0)
await self.send(3, 0, T_INT16, OP_ASSIGN, struct.pack("<HH", bits, targets & 0xFFFF))
async def set_focus_assist(self, method: int, colour: int):
"""4.5 Focus assist: método 0 = peak, 1 = líneas de color; color 0 rojo, 1 verde, 2 azul, 3 blanco, 4 negro."""
await self.send(4, 5, T_INT8, OP_ASSIGN, bytes([method & 0xFF, colour & 0xFF]))
async def set_peaking(self, enabled: bool, colour: int, zebra: bool = False, false_colour: bool = False,
targets: int = 3):
"""Activa el focus assist en modo líneas de color y lo enciende/apaga en las ayudas (4.1)."""
if enabled:
await self.set_focus_assist(1, colour)
await self.set_tools(zebra, enabled, false_colour, targets)
# --- Formato de grabación -------------------------------------------
async def set_recording_format(self, file_fps: int, sensor_fps: int, width: int, height: int, *,
m_rate: bool = False, sensor_m_rate: bool = False, off_speed: bool = False,
windowed: bool = False, interlaced: bool = False):
"""1.9: [fps archivo, fps sensor (0 = sin cambio), ancho, alto, flags]."""
flags = ((1 if m_rate else 0) | (2 if sensor_m_rate else 0) | (4 if off_speed else 0)
| (8 if interlaced else 0) | (16 if windowed else 0))
await self.send(1, 9, T_INT16, OP_ASSIGN,
struct.pack("<hhhhh", int(file_fps), int(sensor_fps), int(width), int(height), flags))
async def set_codec(self, basic: int, variant: int):
"""10.0: códec básico (2 ProRes, 3 Blackmagic RAW) y variante."""
await self.send(10, 0, T_INT8, OP_ASSIGN, bytes([basic & 0xFF, variant & 0xFF]))
async def set_zebra_level(self, value: float):
await self.send(4, 2, T_FIXED16, OP_ASSIGN, enc_fixed16(value))
async def set_peaking_level(self, value: float):
await self.send(4, 3, T_FIXED16, OP_ASSIGN, enc_fixed16(value))
async def show_colour_bars(self, seconds: int):
await self.send(4, 4, T_INT8, OP_ASSIGN, bytes([max(0, min(30, int(seconds)))]))
# --- Color -----------------------------------------------------------
async def reset_colour_correction(self):
await self.send(8, 7, T_VOID, OP_ASSIGN)
# --- Objetivo (resto) --------------------------------------------------
async def set_zoom_mm(self, mm: int):
await self.send(0, 7, T_INT16, OP_ASSIGN, struct.pack("<h", int(mm)))
async def set_zoom_speed(self, speed: float):
"""0.9 zoom continuo: -1 (wide rápido) .. 0 (parar) .. +1 (tele rápido)."""
await self.send(0, 9, T_FIXED16, OP_ASSIGN, enc_fixed16(max(-1.0, min(1.0, speed))))
# --- Vídeo (resto) -----------------------------------------------------
async def set_exposure_us(self, us: int):
await self.send(1, 5, T_INT32, OP_ASSIGN, struct.pack("<i", max(1, min(42000, int(us)))))
async def set_sharpening(self, level: int):
"""1.8: 0 off, 1 bajo, 2 medio, 3 alto."""
await self.send(1, 8, T_INT8, OP_ASSIGN, bytes([max(0, min(3, int(level)))]))
# --- Audio -------------------------------------------------------------
async def set_mic_level(self, v: float):
await self.send(2, 0, T_FIXED16, OP_ASSIGN, enc_fixed16(max(0.0, min(1.0, v))))
async def set_headphone_level(self, v: float):
await self.send(2, 1, T_FIXED16, OP_ASSIGN, enc_fixed16(max(0.0, min(1.0, v))))
async def set_headphone_mix(self, v: float):
await self.send(2, 2, T_FIXED16, OP_ASSIGN, enc_fixed16(max(0.0, min(1.0, v))))
async def set_speaker_level(self, v: float):
await self.send(2, 3, T_FIXED16, OP_ASSIGN, enc_fixed16(max(0.0, min(1.0, v))))
async def set_audio_input_type(self, kind: int):
"""2.4: 0 micro interno, 1 línea, 2 micro nivel bajo, 3 micro nivel alto."""
await self.send(2, 4, T_INT8, OP_ASSIGN, bytes([max(0, min(3, int(kind)))]))
async def set_input_levels(self, ch0: float, ch1: float):
await self.send(2, 5, T_FIXED16, OP_ASSIGN,
enc_fixed16(max(0.0, min(1.0, ch0))) + enc_fixed16(max(0.0, min(1.0, ch1))))
async def set_phantom_power(self, on: bool):
await self.send(2, 6, T_VOID, OP_ASSIGN, bytes([1 if on else 0]))
# --- Salida: guías y rejilla (3.3) ------------------------------------
async def set_frame_guides(self, style: int, opacity: int, safe_area: int, grid_bits: int):
"""3.3: estilo 0 off, 1 2.4:1, 2 2.39:1, 3 2.35:1, 4 1.85:1, 5 16:9, 6 14:9, 7 4:3, 8 2:1, 9 4:5, 10 1:1;
opacidad 0-100; área segura 0-100 (0 = off); rejilla bits 1 tercios, 2 cruz, 4 punto, 8 horizonte."""
await self.send(3, 3, T_INT8, OP_ASSIGN,
bytes([style & 0xFF, max(0, min(100, int(opacity))), max(0, min(100, int(safe_area))),
grid_bits & 0x0F]))
# --- Pantalla (resto) --------------------------------------------------
async def set_display_brightness(self, v: float):
await self.send(4, 0, T_FIXED16, OP_ASSIGN, enc_fixed16(max(0.0, min(1.0, v))))
async def set_program_return(self, seconds: int):
await self.send(4, 6, T_INT8, OP_ASSIGN, bytes([max(0, min(30, int(seconds)))]))
async def set_timecode_source(self, source: int):
"""4.7: 0 = clip, 1 = timecode."""
await self.send(4, 7, T_INT8, OP_ASSIGN, bytes([1 if source else 0]))
# --- Tally -------------------------------------------------------------
async def set_tally(self, v: float, which: int = 0):
"""which: 0 ambos (5.0), 1 frontal (5.1), 2 trasero (5.2)."""
await self.send(5, which, T_FIXED16, OP_ASSIGN, enc_fixed16(max(0.0, min(1.0, v))))
# --- Configuración: reloj -----------------------------------------------
async def sync_clock(self):
"""7.0 reloj en BCD (hora UTC HHMMSSFF, fecha YYYYMMDD) y 7.2 zona horaria en minutos."""
now = time.gmtime()
t_bcd = int(time.strftime("%H%M%S", now) + "00", 16)
d_bcd = int(time.strftime("%Y%m%d", now), 16)
await self.send(7, 0, T_INT32, OP_ASSIGN, struct.pack("<ii", t_bcd, d_bcd))
offset_min = -time.timezone // 60 if not time.localtime().tm_isdst else -time.altzone // 60
await self.send(7, 2, T_INT32, OP_ASSIGN, struct.pack("<i", int(offset_min)))
# --- Corrección de color -----------------------------------------------
async def set_colour_rgby(self, parameter: int, values):
"""8.0 lift, 8.1 gamma, 8.2 gain, 8.3 offset: [rojo, verde, azul, luma]."""
data = b"".join(enc_fixed16(float(v)) for v in list(values)[:4])
await self.send(8, parameter, T_FIXED16, OP_ASSIGN, data)
async def set_contrast(self, pivot: float, adjust: float):
await self.send(8, 4, T_FIXED16, OP_ASSIGN, enc_fixed16(pivot) + enc_fixed16(adjust))
async def set_luma_mix(self, v: float):
await self.send(8, 5, T_FIXED16, OP_ASSIGN, enc_fixed16(max(0.0, min(1.0, v))))
async def set_hue_sat(self, hue: float, sat: float):
await self.send(8, 6, T_FIXED16, OP_ASSIGN, enc_fixed16(hue) + enc_fixed16(sat))
# --- Reproducción (10.1 modo 1 y 10.2) ---------------------------------
async def set_transport(self, mode: int, speed: int = 0, loop: bool = False, play_all: bool = False):
"""mode 0 preview, 1 play, 2 record; speed -n..n; conserva los bytes de ranuras de la cámara."""
cur = self.state.get((10, 1))
data = bytearray(cur[1]) if cur and len(cur[1]) >= 5 else bytearray([0, 0, 0, 0, 0])
data[0] = mode & 0xFF
data[1] = struct.pack("<b", max(-8, min(8, int(speed))))[0]
flags = data[2] & ~0x03
data[2] = flags | (1 if loop else 0) | (2 if play_all else 0)
await self.send(10, 1, T_INT8, OP_ASSIGN, bytes(data))
async def playback_clip(self, next_clip: bool):
await self.send(10, 2, T_INT8, OP_ASSIGN, bytes([1 if next_clip else 0]))
# --- Pizarra / metadatos (12) --------------------------------------------
async def set_reel(self, reel: int):
await self.send(12, 0, T_INT16, OP_ASSIGN, struct.pack("<h", max(0, min(999, int(reel)))))
async def set_scene_tags(self, tag: int, exterior: int, day: int):
"""tag -1 ninguna, 0 WS, 1 CU, 2 MS, 3 BCU, 4 MCU, 5 ECU; exterior 0 int/1 ext; day 0 noche/1 día."""
await self.send(12, 1, T_INT8, OP_ASSIGN, struct.pack("<bbb", int(tag), int(exterior), int(day)))
async def set_scene(self, text: str):
await self.send(12, 2, T_STRING, OP_ASSIGN, text.encode("utf-8")[:5])
async def set_take(self, number: int, tag: int):
"""tag -1 ninguna, 0 PU, 1 VFX, 2 SER."""
await self.send(12, 3, T_INT8, OP_ASSIGN, struct.pack("<bb", max(1, min(99, int(number))), int(tag)))
async def set_good_take(self, good: bool):
await self.send(12, 4, T_INT8, OP_ASSIGN, bytes([1 if good else 0]))
async def set_metadata_text(self, parameter: int, text: str, limit: int = 28):
"""12.5 id cámara, 12.6 operador, 12.7 director, 12.8 proyecto."""
await self.send(12, parameter, T_STRING, OP_ASSIGN, text.encode("utf-8")[:limit])
async def set_slate_mode(self, playback: bool):
await self.send(12, 14, T_INT8, OP_ASSIGN, bytes([1 if playback else 0]))
# --- Alimentación ----------------------------------------------------
async def power_off(self):
if not self.connected:
raise RuntimeError("No hay cámara conectada.")
await self.client.write_gatt_char(CH_STATUS, bytes([0x00]), response=True)

370
dock.html Archivo normal
Ver fichero

@@ -0,0 +1,370 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Labmagic</title>
<link rel="icon" href="/icon.png">
<style>
:root {
--bg: #0d0d0d; --hud: #000; --card: #181818; --border: #2a2a2a; --btn: #2b2b2b; --btn-hover: #3c3c3c;
--trough: #303030; --knob: #ececec; --fg: #f3f3f3; --muted: #9b9b9b; --dim: #555; --rec: #e3262c;
--on: #3ad66c; --busy: #f0a500; --off: #4a4a4a; --sheet: #161616;
}
* { box-sizing: border-box; -webkit-user-select: none; user-select: none; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--fg);
font: 13px "Segoe UI", system-ui, sans-serif; overflow-x: hidden; }
button { font: inherit; color: var(--fg); background: var(--btn); border: 1px solid transparent; border-radius: 6px;
padding: 7px 10px; cursor: pointer; }
button:hover { background: var(--btn-hover); }
button:active { background: #555; }
button:disabled { opacity: .45; cursor: default; }
button.on { border-color: var(--rec); background: #2a1416; }
button.big { font-size: 16px; font-weight: 600; padding: 12px 10px; letter-spacing: .5px; }
button.sq { padding: 7px 0; width: 30px; }
/* HUD */
.hud { background: var(--hud); padding: 7px 10px 6px; }
.top { display: flex; align-items: center; gap: 8px; }
.led { width: 11px; height: 11px; border-radius: 50%; background: var(--off); flex: none; }
.led.on { background: var(--on); box-shadow: 0 0 6px var(--on); }
.led.busy { background: var(--busy); }
.name { font-weight: 600; font-size: 14px; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.name:hover { text-decoration: underline; }
.tc { margin-left: auto; font: 600 22px "Consolas", "Segoe UI", monospace; letter-spacing: 1px; white-space: nowrap; }
.tc.rec { color: var(--rec); }
.dot { color: transparent; font-size: 12px; width: 10px; }
.dot.rec { color: var(--rec); animation: blink 1s steps(1) infinite; }
@keyframes blink { 50% { color: transparent; } }
.status { color: var(--muted); font-size: 11px; margin-top: 1px; display: flex; gap: 8px; }
.status .pw { margin-left: auto; }
.cells { display: grid; grid-template-columns: repeat(6, 1fr); gap: 3px; margin-top: 6px; }
@media (max-width: 330px) { .cells { grid-template-columns: repeat(3, 1fr); } }
.cell { background: #0a0a0a; border: 1px solid #1c1c1c; border-radius: 6px; padding: 4px 2px 5px; text-align: center; }
.cell .l { color: var(--muted); font-size: 9px; letter-spacing: .8px; }
.cell .v { font: 600 15px "Segoe UI", sans-serif; margin-top: 1px; white-space: nowrap; overflow: hidden; }
.cell.click { cursor: pointer; }
.cell.click .v { border-bottom: 1px solid #3a3a3a; display: inline-block; padding: 0 2px; }
.cell.click:hover { background: #151515; }
.cell.click:hover .v { border-bottom-color: var(--fg); }
.banner { display: none; padding: 6px 10px; font-size: 12px; background: var(--rec); }
.banner.warn { background: #8a5a00; }
.banner.ok { background: #1f4d2b; }
.banner.show { display: block; }
main { padding: 8px 10px 10px; display: flex; flex-direction: column; gap: 8px; }
.row { display: flex; gap: 6px; align-items: stretch; }
.row > .grow { flex: 1; }
.tog { font-size: 11.5px; padding: 6px 8px; flex: 1; white-space: nowrap; }
.swatch { display: inline-block; width: 10px; height: 10px; border-radius: 50%; vertical-align: -1px; margin-right: 4px;
border: 1px solid #666; }
.srow { display: grid; grid-template-columns: 44px 30px 1fr 30px; gap: 5px; align-items: center; }
.srow .lab { font-size: 12px; color: var(--muted); }
.srow .lab b { display: block; color: var(--fg); font-weight: 600; font-size: 12px; white-space: nowrap; }
input[type=range] { -webkit-appearance: none; width: 100%; height: 28px; background: transparent; margin: 0; cursor: pointer; }
input[type=range]:disabled { opacity: .4; cursor: default; }
input[type=range]::-webkit-slider-runnable-track { height: 6px; background: var(--trough); border-radius: 3px; }
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%;
background: var(--knob); margin-top: -6px; box-shadow: 0 1px 3px #000; }
.rec-row { display: flex; align-items: center; gap: 12px; background: var(--card); border-radius: 8px; padding: 8px 10px; }
.recbtn { width: 46px; height: 46px; border-radius: 50%; background: #1a1a1a; border: 3px solid #444; position: relative;
flex: none; padding: 0; }
.recbtn::after { content: ""; position: absolute; inset: 7px; border-radius: 50%; background: var(--rec); transition: inset .15s, border-radius .15s; }
.recbtn:hover { border-color: #777; }
.recbtn.rec::after { inset: 12px; border-radius: 4px; }
.recinfo .s { font-weight: 700; letter-spacing: 1px; }
.recinfo .s.rec { color: var(--rec); }
.recinfo .i { color: var(--muted); font-size: 11px; margin-top: 2px; }
.foot { display: flex; gap: 8px; color: var(--dim); font-size: 10.5px; align-items: center; }
.foot a { color: var(--muted); cursor: pointer; margin-left: auto; white-space: nowrap; }
.foot a:hover { color: var(--fg); }
/* Selector estilo cámara (hoja inferior) */
.veil { position: fixed; inset: 0; background: rgba(0,0,0,.55); display: none; }
.veil.show { display: block; }
.sheet { position: fixed; left: 0; right: 0; bottom: 0; background: var(--sheet); border-top: 1px solid var(--border);
border-radius: 12px 12px 0 0; padding: 10px 12px 12px; transform: translateY(100%); transition: transform .18s ease-out;
max-height: 85%; overflow-y: auto; }
.sheet.show { transform: none; }
.sh-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 8px; }
.sh-head .t { color: var(--muted); font-size: 11px; letter-spacing: 1px; }
.sh-head .cur { font: 600 20px "Segoe UI", sans-serif; }
.sh-head .x { margin-left: auto; color: var(--muted); cursor: pointer; padding: 0 4px; font-size: 16px; }
.sh-head .x:hover { color: var(--fg); }
.chips { display: grid; grid-template-columns: repeat(auto-fill, minmax(54px, 1fr)); gap: 4px; margin-bottom: 6px; }
.chip { background: #222; border: 1px solid transparent; border-radius: 6px; padding: 7px 2px; text-align: center;
font-weight: 600; cursor: pointer; font-size: 13px; white-space: nowrap; }
.chip:hover { background: #333; }
.chip.dim { color: var(--muted); font-weight: 400; background: #1c1c1c; }
.chip.sel { background: var(--fg); color: #000; }
.chip.wide { grid-column: span 2; }
.sub { color: var(--muted); font-size: 10.5px; letter-spacing: .8px; margin: 6px 0 3px; }
.note { color: var(--dim); font-size: 10.5px; margin-top: 4px; }
.kv { display: grid; grid-template-columns: 36px 1fr 36px; gap: 6px; align-items: center; font-size: 10.5px; color: var(--muted); }
.ticks { display: flex; justify-content: space-between; padding: 0 42px; color: var(--dim); font-size: 9px; }
</style>
</head>
<body>
<header class="hud">
<div class="top">
<span class="led" id="led"></span>
<span class="name" id="name" title="Pulsa para conectar o desconectar">Sin cámara</span>
<span class="tc" id="tc">00:00:00:00</span><span class="dot" id="dot">●</span>
</div>
<div class="status"><span id="status">Buscando Labmagic…</span><span class="pw" id="power"></span></div>
<div class="cells">
<div class="cell"><div class="l">FPS</div><div class="v" id="c_fps">—</div></div>
<div class="cell click" data-pick="shutter"><div class="l">SHUTTER</div><div class="v" id="c_sh">—</div></div>
<div class="cell click" data-pick="iris"><div class="l">IRIS</div><div class="v" id="c_iris">—</div></div>
<div class="cell click" data-pick="iso"><div class="l">ISO</div><div class="v" id="c_iso">—</div></div>
<div class="cell click" data-pick="wb"><div class="l">WB</div><div class="v" id="c_wb">—</div></div>
<div class="cell click" data-pick="wb"><div class="l">TINT</div><div class="v" id="c_tint">—</div></div>
</div>
</header>
<div class="banner" id="banner"></div>
<main>
<div class="row">
<button class="big grow" id="af" title="Autofoco (también con la barra espaciadora)">AUTOFOCO&nbsp; (AF)</button>
<button class="tog" id="peak" style="flex:0 0 auto" title="Focus assist con líneas de color en la salida elegida en Labmagic"><span class="swatch" id="peaksw"></span>PEAK</button>
</div>
<div class="row">
<button class="tog" id="zebra" title="Zebra en la salida elegida">ZEBRA</button>
<button class="tog" id="false" title="Falso color en la salida elegida">FALSO COLOR</button>
<button class="tog" id="afauto" title="Vuelve a enfocar cada pocos segundos (intervalo en Labmagic → Ajustes)">AF AUTO</button>
</div>
<div class="srow">
<div class="lab">Foco<b id="focus_txt">—</b></div>
<button class="sq" id="f_minus" title="Más cerca (Mayús: paso grande)">◀</button>
<input type="range" id="focus" min="0" max="1000" value="500" disabled>
<button class="sq" id="f_plus" title="Más lejos (Mayús: paso grande)">▶</button>
</div>
<div class="srow">
<div class="lab">Iris<b id="iris_txt">—</b></div>
<button class="sq" id="i_minus" title="Abrir">◀</button>
<input type="range" id="iris" min="0" max="1000" value="0" disabled>
<button class="sq" id="i_plus" title="Cerrar">▶</button>
</div>
<div class="rec-row">
<button class="recbtn" id="rec" title="Grabar / parar"></button>
<div class="recinfo"><div class="s" id="recstate">GRABAR</div><div class="i" id="recinfo">—</div></div>
</div>
<div class="foot"><span id="lens"></span><a id="open" title="Trae la ventana de Labmagic al frente">abrir Labmagic ⇱</a></div>
</main>
<div class="veil" id="veil"></div>
<div class="sheet" id="sheet"></div>
<script>
(() => {
const $ = id => document.getElementById(id);
const ISO_FULL = [100, 200, 400, 800, 1600, 3200, 6400, 12800, 25600];
const ISO_ALL = [100,125,160,200,250,320,400,500,640,800,1000,1250,1600,2000,2500,3200,4000,5000,6400,8000,10000,12800,16000,20000,25600];
const ANG_MAIN = [45, 90, 172.8, 180, 270, 360];
const ANG_ALL = [11.2,15,22.5,30,37.5,45,60,72,90,108,120,144,150,172.8,180,216,270,360];
const SPEEDS = [24,25,30,48,50,60,100,120,125,200,250,500,1000,2000];
const FSTOPS = [1.4,1.8,2.0,2.8,3.5,4.0,4.5,5.6,6.3,8.0,11,16,22];
const WB_CHIPS = [["☀ Día",5600,10],["💡 Tungst.",3200,0],["▭ Fluor.",4000,15],["☁ Nublado",6500,10],["⌂ Sombra",7500,10]];
const PEAK_COL = ["#e3262c","#3ad66c","#3b82f6","#f3f3f3","#000"];
const PEAK_NAME = ["Rojo","Verde","Azul","Blanco","Negro"];
let S = {}; // último estado recibido
let dragging = null; // deslizador que se está arrastrando ("focus" | "iris" | "wb" | "tint")
let bannerT = null;
let alive = false;
const cmd = (c, extra = {}) =>
fetch("/cmd", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ cmd: c, ...extra }) })
.catch(() => {});
// Envío con límite de ritmo para los deslizadores
const pending = {};
let flushT = null;
function throttled(key, fn) {
pending[key] = fn;
if (!flushT) flushT = setTimeout(() => { flushT = null; const p = { ...pending }; for (const k in pending) delete pending[k]; Object.values(p).forEach(f => f()); }, 120);
}
function banner(text, level = "error", secs = 6) {
const b = $("banner");
b.textContent = text; b.className = "banner show " + level;
clearTimeout(bannerT);
bannerT = setTimeout(() => b.className = "banner", secs * 1000);
}
function render(s) {
S = s;
const on = !!s.connected;
$("led").className = "led " + (on ? "on" : (s.busy ? "busy" : ""));
$("name").textContent = s.name || "Sin cámara";
$("status").textContent = s.status || "";
$("power").textContent = s.power || "";
$("tc").textContent = s.tc || "00:00:00:00";
$("tc").className = "tc" + (s.rec ? " rec" : "");
$("dot").className = "dot" + (s.rec ? " rec" : "");
$("c_fps").textContent = s.fps || "—";
$("c_sh").textContent = s.shutter || "—";
$("c_iris").textContent = s.iris || "—";
$("c_iso").textContent = s.iso || "—";
$("c_wb").textContent = s.wb ? s.wb + "K" : "—";
$("c_tint").textContent = (s.tint === 0 || s.tint) ? (s.tint > 0 ? "+" + s.tint : String(s.tint)) : "—";
$("peak").classList.toggle("on", !!s.peak);
$("peaksw").style.background = PEAK_COL[s.peak_colour || 0];
$("zebra").classList.toggle("on", !!s.zebra);
$("false").classList.toggle("on", !!s.false);
$("afauto").classList.toggle("on", !!s.af_auto);
for (const id of ["af","peak","zebra","false","afauto","f_minus","f_plus","i_minus","i_plus","rec"]) $(id).disabled = !on;
$("focus").disabled = !on; $("iris").disabled = !on;
if (dragging !== "focus") { if (s.focus !== null && s.focus !== undefined) $("focus").value = Math.round(s.focus * 1000); }
if (dragging !== "iris") { if (s.aperture !== null && s.aperture !== undefined) $("iris").value = Math.round(s.aperture * 1000); }
$("focus_txt").textContent = s.focus_text || "—";
$("iris_txt").textContent = s.iris || "—";
$("rec").classList.toggle("rec", !!s.rec);
$("recstate").textContent = s.rec ? "GRABANDO" : "GRABAR";
$("recstate").className = "s" + (s.rec ? " rec" : "");
$("recinfo").textContent = s.rec_info || (on ? "" : "—");
$("lens").textContent = s.lens || "";
if (s.notice && s.notice.t !== (render.lastNotice || 0)) { render.lastNotice = s.notice.t; banner(s.notice.text, s.notice.level, s.notice.secs || 6); }
if (sheetKind) refreshSheet();
}
// --- conexión con Labmagic (SSE con reintento)
function listen() {
const es = new EventSource("/events");
es.onopen = () => { alive = true; };
es.onmessage = e => { try { render(JSON.parse(e.data)); } catch (_) {} };
es.onerror = () => {
es.close(); alive = false;
$("led").className = "led"; $("status").textContent = "Labmagic no está abierta: ábrela y este panel se conecta solo";
setTimeout(listen, 2000);
};
}
listen();
// --- botones
$("name").onclick = () => cmd("connect");
$("af").onclick = () => { cmd("af"); const b = $("af"); b.textContent = "AF ● enviado"; setTimeout(() => b.innerHTML = "AUTOFOCO&nbsp; (AF)", 900); };
$("peak").onclick = () => cmd("peak", { on: !S.peak });
$("peak").oncontextmenu = e => { e.preventDefault(); openSheet("peak"); };
$("zebra").onclick = () => cmd("zebra", { on: !S.zebra });
$("false").onclick = () => cmd("false", { on: !S.false });
$("afauto").onclick = () => cmd("af_auto", { on: !S.af_auto });
$("rec").onclick = () => cmd("record");
$("open").onclick = () => cmd("show");
const step = e => (e.shiftKey ? 0.05 : 0.01);
$("f_minus").onclick = e => cmd("focus_delta", { d: -step(e) });
$("f_plus").onclick = e => cmd("focus_delta", { d: step(e) });
$("i_minus").onclick = e => cmd("iris_delta", { d: -0.05 });
$("i_plus").onclick = e => cmd("iris_delta", { d: 0.05 });
for (const [id, key, c] of [["focus", "focus", "focus"], ["iris", "iris", "iris"]]) {
const el = $(id);
el.oninput = () => { dragging = key; const v = el.value / 1000; if (key === "focus") $("focus_txt").textContent = v.toFixed(2); throttled(c, () => cmd(c, { v })); };
el.onchange = () => { const v = el.value / 1000; cmd(c, { v }); setTimeout(() => dragging = null, 300); };
}
// Teclado: espacio = AF, flechas = foco (cuando el panel tiene el foco)
document.addEventListener("keydown", e => {
if (!S.connected) return;
if (e.code === "Space") { e.preventDefault(); $("af").click(); }
else if (e.code === "ArrowLeft") { e.preventDefault(); cmd("focus_delta", { d: -step(e) }); }
else if (e.code === "ArrowRight") { e.preventDefault(); cmd("focus_delta", { d: step(e) }); }
});
// --- selectores (hoja inferior)
let sheetKind = null;
const wbState = { k: 5600, t: 0 };
document.querySelectorAll(".cell.click").forEach(c => c.onclick = () => openSheet(c.dataset.pick));
$("veil").onclick = closeSheet;
function closeSheet() { sheetKind = null; $("sheet").classList.remove("show"); $("veil").classList.remove("show"); }
function openSheet(kind) {
if (!S.connected) { banner("Conecta la cámara para cambiar valores", "warn", 3); return; }
sheetKind = kind;
if (kind === "wb") { wbState.k = S.wb || 5600; wbState.t = S.tint || 0; }
buildSheet(kind);
$("sheet").classList.add("show"); $("veil").classList.add("show");
}
const h = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; };
function chips(parent, items, cur, onpick, dim) {
const g = h("div", "chips");
for (const [label, val] of items) {
const c = h("div", "chip" + (dim && dim.has(val) ? " dim" : "") + (cur !== null && cur !== undefined && Math.abs(cur - val) < 1e-6 ? " sel" : ""), label);
c.onclick = () => onpick(val);
g.appendChild(c);
}
parent.appendChild(g);
return g;
}
function head(parent, title, cur) {
const hd = h("div", "sh-head");
hd.appendChild(h("span", "t", title)); const cv = h("span", "cur", cur); hd.appendChild(cv);
const x = h("span", "x", "✕"); x.onclick = closeSheet; hd.appendChild(x);
parent.appendChild(hd);
return cv;
}
function refreshSheet() { if (sheetKind && sheetKind !== "wb") buildSheet(sheetKind); }
function buildSheet(kind) {
const sh = $("sheet"); sh.innerHTML = "";
if (kind === "iso") {
const cur = parseInt(S.iso) || null;
head(sh, "ISO", S.iso || "—");
const pick = v => { cmd("iso", { v }); closeSheet(); };
chips(sh, ISO_FULL.map(v => [String(v), v]), cur, pick);
chips(sh, ISO_ALL.filter(v => !ISO_FULL.includes(v)).map(v => [String(v), v]), cur, pick, new Set(ISO_ALL));
sh.appendChild(h("div", "note", "Nativos de la Pocket 4K: 400 y 3200 · tercios en gris"));
} else if (kind === "shutter") {
head(sh, "OBTURADOR", S.shutter || "—");
const curA = S.angle, curS = S.speed;
sh.appendChild(h("div", "sub", "ÁNGULO"));
const pa = a => { cmd("angle", { a }); closeSheet(); };
chips(sh, ANG_MAIN.map(a => [a + "°", a]), curA, pa);
chips(sh, ANG_ALL.filter(a => !ANG_MAIN.includes(a)).map(a => [a + "°", a]), curA, pa, new Set(ANG_ALL));
sh.appendChild(h("div", "sub", "VELOCIDAD 1/x"));
chips(sh, SPEEDS.map(s => ["1/" + s, s]), curS, s => { cmd("speed", { s }); closeSheet(); });
} else if (kind === "iris") {
head(sh, "IRIS", S.iris || "—");
chips(sh, FSTOPS.map(f => ["f/" + f, f]), null, f => { cmd("iris_f", { f }); closeSheet(); });
const kv = h("div", "kv"); kv.appendChild(h("span", "", "abierto"));
const r = h("input"); r.type = "range"; r.min = 0; r.max = 1000; r.value = Math.round((S.aperture || 0) * 1000);
r.oninput = () => { dragging = "iris"; throttled("iris", () => cmd("iris", { v: r.value / 1000 })); };
r.onchange = () => { cmd("iris", { v: r.value / 1000 }); setTimeout(() => dragging = null, 300); };
kv.appendChild(r); kv.appendChild(h("span", "", "cerrado")); sh.appendChild(kv);
const g = h("div", "chips"); const a = h("div", "chip", "AUTO IRIS"); a.onclick = () => { cmd("auto_iris"); closeSheet(); }; g.appendChild(a); sh.appendChild(g);
} else if (kind === "wb") {
const cv = head(sh, "BALANCE DE BLANCOS", "");
const refresh = () => cv.textContent = `${wbState.k}K ${wbState.t >= 0 ? "+" : ""}${wbState.t}`;
const send = () => throttled("wb", () => cmd("wb", { k: wbState.k, t: wbState.t }));
refresh();
const kv = h("div", "kv"); kv.appendChild(h("span", "", "2500"));
const ks = h("input"); ks.type = "range"; ks.min = 2500; ks.max = 10000; ks.step = 50; ks.value = wbState.k;
ks.oninput = () => { wbState.k = +ks.value; refresh(); send(); markPreset(); };
kv.appendChild(ks); kv.appendChild(h("span", "", "10000")); sh.appendChild(kv);
const ticks = h("div", "ticks"); for (const k of [3200, 4000, 5600, 6500, 7500]) ticks.appendChild(h("span", "", String(k))); sh.appendChild(ticks);
const g = h("div", "chips"); g.style.gridTemplateColumns = "repeat(3, 1fr)";
const presetChips = [];
for (const [label, k, t] of WB_CHIPS) {
const c = h("div", "chip", label); c.dataset.k = k;
c.onclick = () => { wbState.k = k; wbState.t = t; ks.value = k; ts.value = t; refresh(); send(); markPreset(); };
g.appendChild(c); presetChips.push(c);
}
const awb = h("div", "chip", "AUTO WB"); awb.onclick = () => { cmd("awb"); closeSheet(); }; g.appendChild(awb);
sh.appendChild(g);
const markPreset = () => presetChips.forEach(c => c.classList.toggle("sel", +c.dataset.k === wbState.k));
const tr = h("div", "kv"); tr.style.gridTemplateColumns = "36px 1fr 30px 30px";
tr.appendChild(h("span", "", "Tinte"));
const ts = h("input"); ts.type = "range"; ts.min = -50; ts.max = 50; ts.value = wbState.t;
ts.oninput = () => { wbState.t = +ts.value; refresh(); send(); };
tr.appendChild(ts);
for (const [txt, d] of [["−", -1], ["+", 1]]) { const b = h("button", "sq", txt); b.onclick = () => { wbState.t = Math.max(-50, Math.min(50, wbState.t + d)); ts.value = wbState.t; refresh(); send(); }; tr.appendChild(b); }
sh.appendChild(tr);
markPreset();
} else if (kind === "peak") {
head(sh, "COLOR DEL PEAKING", PEAK_NAME[S.peak_colour || 0]);
const g = h("div", "chips");
PEAK_NAME.forEach((n, i) => { const c = h("div", "chip" + (i === (S.peak_colour || 0) ? " sel" : "")); c.innerHTML = `<span class="swatch" style="background:${PEAK_COL[i]}"></span>${n}`; c.onclick = () => { cmd("peak_colour", { c: i }); closeSheet(); }; g.appendChild(c); });
sh.appendChild(g);
sh.appendChild(h("div", "note", "Clic derecho en PEAK abre este selector"));
}
}
})();
</script>
</body>
</html>

BIN
icono.ico Archivo normal

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 35 KiB

BIN
icono.png Archivo normal

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 3.4 KiB

BIN
iconos/opcion-1.ico Archivo normal

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 33 KiB

BIN
iconos/opcion-1.png Archivo normal

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 3.0 KiB

BIN
iconos/opcion-2.ico Archivo normal

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 35 KiB

BIN
iconos/opcion-2.png Archivo normal

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 3.4 KiB

BIN
iconos/opcion-3.ico Archivo normal

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 32 KiB

BIN
iconos/opcion-3.png Archivo normal

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 3.1 KiB

BIN
iconos/opcion-4.ico Archivo normal

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 41 KiB

BIN
iconos/opcion-4.png Archivo normal

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 4.1 KiB

BIN
iconos/opciones.png Archivo normal

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 92 KiB

140
make_icon.py Archivo normal
Ver fichero

@@ -0,0 +1,140 @@
"""
Genera el icono de Labmagic a partir del wordmark blanco real de Labstream.
- icono.ico / icono.png: la opción elegida (OPCION_POR_DEFECTO).
- iconos/opcion-N.ico|png: todas las opciones, por si se quiere cambiar (basta copiar una sobre icono.ico).
- iconos/opciones.png: hoja de muestra con las opciones a 256 px y a tamaño pequeño.
"""
import os
from PIL import Image, ImageDraw, ImageFont
HERE = os.path.dirname(os.path.abspath(__file__))
LOGO_BLANCO = r"C:\Users\Munka\OneDrive\Labstream\Propuestas\0_Modelo Propuestas\Logos\Logo Blanco.png"
OPCION_POR_DEFECTO = 2
ROJO = (227, 38, 44, 255)
ROJO_OSCURO = (170, 22, 28, 255)
NEGRO = (16, 16, 16, 255)
BLANCO = (255, 255, 255, 255)
SS = 4 # supermuestreo
def cargar_wordmark():
"""Devuelve (wordmark 'labstream', 'lab' solo, logo completo con STUDIO), todos RGBA blancos recortados."""
img = Image.open(LOGO_BLANCO).convert("RGBA")
w, h = img.size
full = img.crop(img.getbbox())
word = img.crop((0, 0, w, int(h * 0.66)))
word = word.crop(word.getbbox())
lab = word.crop((0, 0, int(word.width * 0.295), word.height))
lab = lab.crop(lab.getbbox())
return word, lab, full
def fondo(size, color, radio=0.22, borde=None, grosor=0.035, inset=0.065):
s = size * SS
img = Image.new("RGBA", (s, s), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
d.rounded_rectangle((0, 0, s - 1, s - 1), radius=int(s * radio), fill=color)
if borde:
i = int(s * inset)
d.rounded_rectangle((i, i, s - 1 - i, s - 1 - i), radius=int(s * (radio - inset * 0.8)),
outline=borde, width=max(1, int(s * grosor)))
return img
def pegar_centrado(base, art, ancho_rel, dy_rel=0.0):
s = base.width
ratio = art.height / art.width
w = int(s * ancho_rel)
h = int(w * ratio)
art = art.resize((w, h), Image.LANCZOS)
x = (s - w) // 2
y = int((s - h) / 2 + s * dy_rel)
base.alpha_composite(art, (x, y))
return base
def punto(base, cx_rel, cy_rel, r_rel, color):
s = base.width
d = ImageDraw.Draw(base)
cx, cy, r = s * cx_rel, s * cy_rel, s * r_rel
d.ellipse((cx - r, cy - r, cx + r, cy + r), fill=color)
return base
def opcion(n, size):
word, lab, full = cargar_wordmark()
if n == 1: # Rojo, borde blanco fino, wordmark blanco
base = fondo(size, ROJO, borde=BLANCO)
return pegar_centrado(base, word, 0.74)
if n == 2: # Rojo, wordmark grande "lab" + punto REC blanco
base = fondo(size, ROJO, borde=BLANCO)
pegar_centrado(base, lab, 0.56, dy_rel=-0.03)
return punto(base, 0.5, 0.78, 0.055, BLANCO)
if n == 3: # Negro con borde rojo, wordmark blanco y punto rojo
base = fondo(size, NEGRO, borde=ROJO, grosor=0.05)
pegar_centrado(base, word, 0.74, dy_rel=-0.04)
return punto(base, 0.5, 0.74, 0.05, ROJO)
if n == 4: # Rojo oscuro degradado, logo completo (labstream + STUDIO)
s = size * SS
grad = Image.new("RGBA", (s, s), ROJO)
gd = ImageDraw.Draw(grad)
for y in range(s):
t = y / s
c = tuple(int(ROJO[i] * (1 - t) + ROJO_OSCURO[i] * t) for i in range(3)) + (255,)
gd.line((0, y, s, y), fill=c)
mask = fondo(size, BLANCO)
base = Image.new("RGBA", (s, s), (0, 0, 0, 0))
base.paste(grad, (0, 0), mask)
ImageDraw.Draw(base).rounded_rectangle((int(s * 0.065), int(s * 0.065), s - 1 - int(s * 0.065), s - 1 - int(s * 0.065)),
radius=int(s * 0.17), outline=BLANCO, width=max(1, int(s * 0.03)))
return pegar_centrado(base, full, 0.72)
raise ValueError(n)
def render(n, size):
return opcion(n, size).resize((size, size), Image.LANCZOS)
def guardar_ico(n, ruta_ico, ruta_png):
sizes = [16, 24, 32, 48, 64, 128, 256]
imgs = [render(n, s) for s in sizes]
imgs[-1].save(ruta_ico, format="ICO", sizes=[(s, s) for s in sizes], append_images=imgs[:-1])
render(n, 64).save(ruta_png)
def hoja(ruta):
"""Hoja de muestra: cada opción a 256 px, y a 48/32/16 px para ver cómo se lee en pequeño."""
try:
font = ImageFont.truetype("C:/Windows/Fonts/segoeui.ttf", 22)
fontb = ImageFont.truetype("C:/Windows/Fonts/segoeuib.ttf", 26)
except Exception: # noqa: BLE001
font = fontb = ImageFont.load_default()
nombres = {1: "1 · Rojo, borde blanco, wordmark", 2: "2 · Rojo, 'lab' grande + punto",
3: "3 · Negro, borde rojo, wordmark", 4: "4 · Rojo degradado, logo completo"}
col_w, pad = 360, 40
sheet = Image.new("RGBA", (col_w * 4 + pad, 470), (24, 24, 24, 255))
d = ImageDraw.Draw(sheet)
d.text((pad, 14), "Opciones de icono para Labmagic", fill=(240, 240, 240, 255), font=fontb)
for i, n in enumerate((1, 2, 3, 4)):
x0 = pad + i * col_w
sheet.alpha_composite(render(n, 256), (x0, 60))
y = 340
xx = x0
for s in (48, 32, 16):
sheet.alpha_composite(render(n, s), (xx, y + (48 - s) // 2))
xx += s + 14
d.text((x0, 405), nombres[n], fill=(200, 200, 200, 255), font=font)
sheet.save(ruta)
if __name__ == "__main__":
out_dir = os.path.join(HERE, "iconos")
os.makedirs(out_dir, exist_ok=True)
for n in (1, 2, 3, 4):
guardar_ico(n, os.path.join(out_dir, f"opcion-{n}.ico"), os.path.join(out_dir, f"opcion-{n}.png"))
guardar_ico(OPCION_POR_DEFECTO, os.path.join(HERE, "icono.ico"), os.path.join(HERE, "icono.png"))
hoja(os.path.join(out_dir, "opciones.png"))
print("icono por defecto: opción", OPCION_POR_DEFECTO, "->", os.path.join(HERE, "icono.ico"))

247
obs_dock.py Archivo normal
Ver fichero

@@ -0,0 +1,247 @@
"""
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)."

1
requirements.txt Archivo normal
Ver fichero

@@ -0,0 +1 @@
bleak>=3.0